From 62b04153bec8f05017b818559593632d62a53657 Mon Sep 17 00:00:00 2001 From: Frederik Schmatz Date: Thu, 20 Aug 2026 07:56:35 +0200 Subject: [PATCH 1/2] feat: new utils dotifyQuery & nestifyQuery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Convert Feathers queries between dot notation (`{ 'user.name': 'x' }`) and nested objects (`{ user: { name: 'x' } }`). Queries arrive in both shapes depending on where they come from, but adapters only reliably understand the dot form, so every call site had to normalize this by hand. Both are query-aware rather than a generic flatten/unflatten: - operators (`$ne`, `$in`, ...) never become path segments - `$or`/`$and`/`$nor`/`$not` branches are converted per branch - `$sort` keys stay in dot notation in both directions — the only form adapters understand - `$select`, `$limit`, `$skip` and custom operators pass through untouched Because "value vs. path" is not always decidable, both take a per-key predicate (`descend`/`split`) plus declarative `include`/`exclude` shortcuts. Colliding paths never lose data: deep-equal values collapse, objects with disjoint keys merge, and a genuine contradiction is wrapped in `$and` — matching addToQuery. A key whose path is blocked by a non-object value simply stays in dot notation, which is already a valid condition. Co-Authored-By: Claude Opus 5 --- src/common/index.ts | 1 + src/common/query-operators.ts | 16 + src/utils/dotify-query/dotify-keys.ts | 159 +++++++ src/utils/dotify-query/dotify-query.util.md | 9 + .../dotify-query/dotify-query.util.test.ts | 443 ++++++++++++++++++ src/utils/dotify-query/dotify-query.util.ts | 257 ++++++++++ src/utils/index.ts | 2 + src/utils/nestify-query/nestify-query.util.md | 9 + .../nestify-query/nestify-query.util.test.ts | 399 ++++++++++++++++ src/utils/nestify-query/nestify-query.util.ts | 256 ++++++++++ test/index.test.ts | 2 + 11 files changed, 1553 insertions(+) create mode 100644 src/common/query-operators.ts create mode 100644 src/utils/dotify-query/dotify-keys.ts create mode 100644 src/utils/dotify-query/dotify-query.util.md create mode 100644 src/utils/dotify-query/dotify-query.util.test.ts create mode 100644 src/utils/dotify-query/dotify-query.util.ts create mode 100644 src/utils/nestify-query/nestify-query.util.md create mode 100644 src/utils/nestify-query/nestify-query.util.test.ts create mode 100644 src/utils/nestify-query/nestify-query.util.ts diff --git a/src/common/index.ts b/src/common/index.ts index d180a35..b99fe73 100644 --- a/src/common/index.ts +++ b/src/common/index.ts @@ -23,3 +23,4 @@ export { isPlainObject } from './is-plain-object.js' export { dedupeBranches } from './dedupe-branches.js' export { flattenAndBranches } from './flatten-and-branches.js' export { flattenOrBranches } from './flatten-or-branches.js' +export { branchOperators } from './query-operators.js' diff --git a/src/common/query-operators.ts b/src/common/query-operators.ts new file mode 100644 index 0000000..2076288 --- /dev/null +++ b/src/common/query-operators.ts @@ -0,0 +1,16 @@ +/** + * Query operators whose value is an array of sub-queries (branches). Their + * branches have to be traversed individually — they are never part of a + * property path. + */ +export const branchOperators = new Set(['$or', '$and', '$nor']) + +if (import.meta.vitest) { + const { describe, it, expect } = import.meta.vitest + + describe('query-operators', () => { + it('contains the branch operators', () => { + expect([...branchOperators].sort()).toEqual(['$and', '$nor', '$or']) + }) + }) +} diff --git a/src/utils/dotify-query/dotify-keys.ts b/src/utils/dotify-query/dotify-keys.ts new file mode 100644 index 0000000..d50a121 --- /dev/null +++ b/src/utils/dotify-query/dotify-keys.ts @@ -0,0 +1,159 @@ +import { dequal as deepEqual } from 'dequal' +import { dedupeBranches, isPlainObject } from '../../common/index.js' + +/** + * Flattens the keys of a `$sort` object into dot notation, keeping the sort + * directions untouched. Returns the original object if there was nothing to + * flatten. + * + * `$sort` is deliberately kept in dot notation in *both* directions, because + * that is the only form the Feathers adapters understand. Two keys that flatten + * to the same path keep the last direction — `$sort` has no `$and` to fall back + * on. + * + * @internal shared by `dotifyQuery` and `nestifyQuery`. + */ +export const dotifySortKeys = >(sort: T): T => { + let changed = false + const result: Record = {} + + const walk = (node: Record, prefix: string) => { + for (const key of Object.keys(node)) { + const value = node[key] + const path = prefix ? `${prefix}.${key}` : key + + if (isPlainObject(value) && Object.keys(value).length > 0) { + changed = true + walk(value, path) + } else { + result[path] = value + } + } + } + + walk(sort, '') + + return changed ? (result as T) : sort +} + +/** + * Builds a nested object along `segments`, innermost value last — + * `nest(['a', 'b'], 1)` is `{ a: { b: 1 } }`. + * + * @internal shared by `dotifyQuery` and `nestifyQuery`. + */ +export const nest = (segments: string[], value: any): Record => { + let result = value + for (let i = segments.length - 1; i >= 0; i--) { + result = { [segments[i]]: result } + } + return result +} + +/** + * Assigns `value` to `key` on `target` without losing information: + * - the key is free → plain assignment + * - the existing value is deep-equal → nothing to do + * - both sides are objects with disjoint keys → merged + * + * Anything else is a genuine conflict, which is returned as a `{ [key]: value }` + * condition for the caller to add to `$and`. + * + * @internal shared by `dotifyQuery` and `nestifyQuery`. + */ +export const assignPath = ( + target: Record, + key: string, + value: any, +): Record | undefined => { + if (!(key in target)) { + target[key] = value + return undefined + } + + const existing = target[key] + + if (deepEqual(existing, value)) { + return undefined + } + + if ( + isPlainObject(existing) && + isPlainObject(value) && + Object.keys(value).every((subKey) => !(subKey in existing)) + ) { + target[key] = { ...existing, ...value } + return undefined + } + + return { [key]: value } +} + +/** + * Merges conflicting conditions into `target.$and`, de-duplicating against the + * branches that are already there. + * + * @internal shared by `dotifyQuery` and `nestifyQuery`. + */ +export const mergeAndBranches = ( + target: Record, + branches: Record[], +): void => { + const existing = Array.isArray(target.$and) ? target.$and : [] + target.$and = dedupeBranches([...existing, ...branches]) +} + +export type SetNestedResult = { + /** + * `false` when a segment was blocked by a non-object value, so the dotted key + * was kept instead of being nested. + */ + split: boolean + /** A leaf condition that could not be set, for the caller to add to `$and`. */ + conflict?: Record +} + +/** + * Sets `value` at the nested location described by `segments`, creating missing + * levels and cloning existing ones so the input query is never mutated. + * + * When a segment is blocked by a non-object value there is no need to force the + * nested shape: the dotted key is already a valid condition on its own, so it is + * kept as-is and `split: false` is reported. Only a conflicting *leaf* has no + * such fallback and comes back as a `conflict` for `$and`. + * + * @internal used by `nestifyQuery`. + */ +export const setNested = ( + target: Record, + segments: string[], + value: any, +): SetNestedResult => { + let node = target + + for (let i = 0; i < segments.length - 1; i++) { + const segment = segments[i] + + if (!(segment in node)) { + node[segment] = {} + } else if (isPlainObject(node[segment])) { + // clone, so a nested object coming from the input is never mutated + node[segment] = { ...node[segment] } + } else { + // blocked — leave the condition where it is, in dot notation + return { + split: false, + conflict: assignPath(target, segments.join('.'), value), + } + } + + node = node[segment] + } + + return { + split: true, + conflict: assignPath(node, segments[segments.length - 1], value) + ? nest(segments, value) + : undefined, + } +} diff --git a/src/utils/dotify-query/dotify-query.util.md b/src/utils/dotify-query/dotify-query.util.md new file mode 100644 index 0000000..1e84394 --- /dev/null +++ b/src/utils/dotify-query/dotify-query.util.md @@ -0,0 +1,9 @@ +--- +title: dotifyQuery +category: utils +see: + - utils/nestifyQuery + - utils/addToQuery + - utils/walkQuery + - hooks/transformQuery +--- diff --git a/src/utils/dotify-query/dotify-query.util.test.ts b/src/utils/dotify-query/dotify-query.util.test.ts new file mode 100644 index 0000000..b307cea --- /dev/null +++ b/src/utils/dotify-query/dotify-query.util.test.ts @@ -0,0 +1,443 @@ +import { describe, it, expect } from 'vitest' +import { dotifyQuery } from './dotify-query.util.js' + +describe('dotifyQuery', () => { + describe('basic conversion', () => { + it('flattens a one-level nested property', () => { + expect(dotifyQuery({ user: { name: 'x' } })).toEqual({ 'user.name': 'x' }) + }) + + it('flattens a multi-level nested property', () => { + expect(dotifyQuery({ company: { owner: { name: 'x' } } })).toEqual({ + 'company.owner.name': 'x', + }) + }) + + it('flattens several siblings', () => { + expect(dotifyQuery({ user: { name: 'x', age: 5 } })).toEqual({ + 'user.name': 'x', + 'user.age': 5, + }) + }) + + it('keeps already-dotted keys and appends to them', () => { + expect(dotifyQuery({ 'a.b': { c: 1 } })).toEqual({ 'a.b.c': 1 }) + }) + + it('leaves a flat query untouched', () => { + expect(dotifyQuery({ id: 1, name: 'x' })).toEqual({ id: 1, name: 'x' }) + }) + }) + + describe('operators', () => { + it('keeps an operator object as a leaf', () => { + expect(dotifyQuery({ user: { name: { $ne: 'x' } } })).toEqual({ + 'user.name': { $ne: 'x' }, + }) + }) + + it('does not descend into an operator object', () => { + expect(dotifyQuery({ age: { $gt: 18, $lt: 30 } })).toEqual({ + age: { $gt: 18, $lt: 30 }, + }) + }) + + it('splits a mixed operator/property object', () => { + expect(dotifyQuery({ user: { $ne: null, name: 'x' } })).toEqual({ + user: { $ne: null }, + 'user.name': 'x', + }) + }) + + it('keeps $in arrays as values', () => { + expect(dotifyQuery({ user: { role: { $in: ['a', 'b'] } } })).toEqual({ + 'user.role': { $in: ['a', 'b'] }, + }) + }) + }) + + describe('branch operators', () => { + it('converts inside $or', () => { + expect(dotifyQuery({ $or: [{ user: { name: 'a' } }] })).toEqual({ + $or: [{ 'user.name': 'a' }], + }) + }) + + it('converts inside $and', () => { + expect( + dotifyQuery({ $and: [{ user: { name: 'a' } }, { id: 1 }] }), + ).toEqual({ $and: [{ 'user.name': 'a' }, { id: 1 }] }) + }) + + it('converts inside $nor', () => { + expect(dotifyQuery({ $nor: [{ user: { name: 'a' } }] })).toEqual({ + $nor: [{ 'user.name': 'a' }], + }) + }) + + it('converts inside nested branches ($or > $and)', () => { + expect( + dotifyQuery({ $or: [{ $and: [{ user: { name: 'a' } }] }] }), + ).toEqual({ $or: [{ $and: [{ 'user.name': 'a' }] }] }) + }) + + it('does not prefix branch keys with the operator', () => { + const result = dotifyQuery({ $or: [{ user: { name: 'a' } }] }) + expect(Object.keys(result)).toEqual(['$or']) + }) + + it('leaves non-object branch entries alone', () => { + expect(dotifyQuery({ $or: [null, { user: { name: 'a' } }] })).toEqual({ + $or: [null, { 'user.name': 'a' }], + }) + }) + + it('converts $not given as an array of branches', () => { + expect(dotifyQuery({ $not: [{ user: { name: 'a' } }] } as any)).toEqual({ + $not: [{ 'user.name': 'a' }], + }) + }) + + it('converts $not given as a single sub-query', () => { + expect(dotifyQuery({ $not: { user: { name: 'a' } } } as any)).toEqual({ + $not: { 'user.name': 'a' }, + }) + }) + + it('leaves a scalar $not alone', () => { + expect(dotifyQuery({ $not: 1 } as any)).toEqual({ $not: 1 }) + }) + }) + + describe('filters', () => { + it('flattens $sort keys', () => { + expect(dotifyQuery({ $sort: { user: { name: 1 } } })).toEqual({ + $sort: { 'user.name': 1 }, + }) + }) + + it('keeps $sort directions and already-dotted keys', () => { + expect(dotifyQuery({ $sort: { 'user.name': -1, createdAt: 1 } })).toEqual( + { $sort: { 'user.name': -1, createdAt: 1 } }, + ) + }) + + it('leaves a non-object $sort alone', () => { + expect(dotifyQuery({ $sort: 'name' } as any)).toEqual({ $sort: 'name' }) + }) + + it('leaves $select untouched', () => { + expect(dotifyQuery({ $select: ['user.name'] })).toEqual({ + $select: ['user.name'], + }) + }) + + it('leaves $limit and $skip untouched', () => { + expect(dotifyQuery({ $limit: 10, $skip: 5 })).toEqual({ + $limit: 10, + $skip: 5, + }) + }) + + it('leaves a custom top-level operator untouched', () => { + expect(dotifyQuery({ $fuzzy: { term: 'x' } } as any)).toEqual({ + $fuzzy: { term: 'x' }, + }) + }) + }) + + describe('non-descendable values', () => { + it('leaves a Date alone', () => { + const at = new Date() + expect(dotifyQuery({ at })).toEqual({ at }) + }) + + it('leaves a RegExp alone', () => { + const re = /x/ + expect(dotifyQuery({ name: re })).toEqual({ name: re }) + }) + + it('leaves an array alone', () => { + expect(dotifyQuery({ tags: ['a', 'b'] })).toEqual({ tags: ['a', 'b'] }) + }) + + it('leaves an array of objects alone', () => { + expect(dotifyQuery({ tags: [{ a: 1 }] })).toEqual({ tags: [{ a: 1 }] }) + }) + + it('leaves null and primitives alone', () => { + expect(dotifyQuery({ a: null, b: 1, c: 'x', d: false })).toEqual({ + a: null, + b: 1, + c: 'x', + d: false, + }) + }) + + it('leaves an empty object alone', () => { + expect(dotifyQuery({ user: {} })).toEqual({ user: {} }) + }) + + it('returns a non-object query as-is', () => { + expect(dotifyQuery(null as any)).toBe(null) + }) + }) + + describe('descend predicate', () => { + it('false stops the descent', () => { + expect(dotifyQuery({ meta: { a: 1 } }, { descend: () => false })).toEqual( + { meta: { a: 1 } }, + ) + }) + + it('true forces the descent of an operator-only object', () => { + expect( + dotifyQuery({ user: { $ne: 1 } }, { descend: () => true }), + ).toEqual({ user: { $ne: 1 } }) + }) + + it('undefined falls through to the default heuristic', () => { + expect( + dotifyQuery({ user: { name: 'x' } }, { descend: () => undefined }), + ).toEqual({ 'user.name': 'x' }) + }) + + it('receives key, path and value', () => { + const seen: { key: string; path: string; value: any }[] = [] + dotifyQuery( + { company: { owner: { name: 'x' } } }, + { + descend: (options) => { + seen.push({ ...options }) + return undefined + }, + }, + ) + expect(seen).toEqual([ + { key: 'company', path: 'company', value: { owner: { name: 'x' } } }, + { key: 'owner', path: 'company.owner', value: { name: 'x' } }, + ]) + }) + + it('is not called for leaf values', () => { + let calls = 0 + dotifyQuery( + { id: 1, at: new Date(), user: {}, age: { $gt: 1 } }, + { + descend: () => { + calls++ + return undefined + }, + }, + ) + expect(calls).toBe(1) // only `age`, the only non-empty plain object + }) + + it('takes precedence over exclude', () => { + expect( + dotifyQuery( + { user: { name: 'x' } }, + { exclude: ['user'], descend: () => true }, + ), + ).toEqual({ 'user.name': 'x' }) + }) + + it('takes precedence over include', () => { + expect( + dotifyQuery( + { user: { name: 'x' } }, + { include: ['other'], descend: () => true }, + ), + ).toEqual({ 'user.name': 'x' }) + }) + + it('supports depth-agnostic key matching', () => { + expect( + dotifyQuery( + { user: { meta: { a: 1 }, name: 'x' } }, + { descend: ({ key }) => (key === 'meta' ? false : undefined) }, + ), + ).toEqual({ 'user.meta': { a: 1 }, 'user.name': 'x' }) + }) + }) + + describe('exclude / include', () => { + it('excludes a top-level key', () => { + expect(dotifyQuery({ meta: { a: 1 } }, { exclude: ['meta'] })).toEqual({ + meta: { a: 1 }, + }) + }) + + it('excludes a deep path only', () => { + expect( + dotifyQuery( + { user: { meta: { a: 1 }, name: 'x' } }, + { exclude: ['user.meta'] }, + ), + ).toEqual({ 'user.meta': { a: 1 }, 'user.name': 'x' }) + }) + + it('does not match a deep path by its bare key', () => { + expect( + dotifyQuery({ user: { meta: { a: 1 } } }, { exclude: ['meta'] }), + ).toEqual({ 'user.meta.a': 1 }) + }) + + it('include restricts the conversion to the listed paths', () => { + expect( + dotifyQuery( + { user: { name: 'x' }, meta: { a: 1 } }, + { include: ['user'] }, + ), + ).toEqual({ 'user.name': 'x', meta: { a: 1 } }) + }) + + it('include has to list every level of a deep path', () => { + expect( + dotifyQuery( + { company: { owner: { name: 'x' } } }, + { include: ['company'] }, + ), + ).toEqual({ 'company.owner': { name: 'x' } }) + + expect( + dotifyQuery( + { company: { owner: { name: 'x' } } }, + { include: ['company', 'company.owner'] }, + ), + ).toEqual({ 'company.owner.name': 'x' }) + }) + }) + + describe('collisions', () => { + it('merges two operator objects for the same path', () => { + expect( + dotifyQuery({ + 'user.name': { $ne: 'a' }, + user: { name: { $gt: 'b' } }, + }), + ).toEqual({ 'user.name': { $ne: 'a', $gt: 'b' } }) + }) + + it('collapses deep-equal values for the same path', () => { + expect(dotifyQuery({ 'user.name': 'a', user: { name: 'a' } })).toEqual({ + 'user.name': 'a', + }) + }) + + it('collapses deep-equal operator objects', () => { + expect( + dotifyQuery({ + 'user.role': { $in: ['a'] }, + user: { role: { $in: ['a'] } }, + }), + ).toEqual({ 'user.role': { $in: ['a'] } }) + }) + + it('wraps contradictory scalar values in $and', () => { + expect(dotifyQuery({ 'user.name': 'a', user: { name: 'b' } })).toEqual({ + 'user.name': 'a', + $and: [{ 'user.name': 'b' }], + }) + }) + + it('wraps overlapping operator objects in $and', () => { + expect( + dotifyQuery({ + 'user.name': { $ne: 'a' }, + user: { name: { $ne: 'b' } }, + }), + ).toEqual({ + 'user.name': { $ne: 'a' }, + $and: [{ 'user.name': { $ne: 'b' } }], + }) + }) + + it('wraps a scalar colliding with an operator object in $and', () => { + expect( + dotifyQuery({ 'user.name': 'a', user: { name: { $ne: 'b' } } }), + ).toEqual({ 'user.name': 'a', $and: [{ 'user.name': { $ne: 'b' } }] }) + }) + + it('appends to an existing $and regardless of key order', () => { + const expected = { + 'user.name': 'a', + $and: [{ x: 1 }, { 'user.name': 'b' }], + } + + // `$and` before the colliding keys + expect( + dotifyQuery({ + $and: [{ x: 1 }], + 'user.name': 'a', + user: { name: 'b' }, + }), + ).toEqual(expected) + + // `$and` after the colliding keys + expect( + dotifyQuery({ + 'user.name': 'a', + user: { name: 'b' }, + $and: [{ x: 1 }], + }), + ).toEqual(expected) + }) + + it('does not duplicate a branch already present in $and', () => { + expect( + dotifyQuery({ + $and: [{ 'user.name': 'b' }], + 'user.name': 'a', + user: { name: 'b' }, + }), + ).toEqual({ 'user.name': 'a', $and: [{ 'user.name': 'b' }] }) + }) + + it('wraps a leaf colliding with an already-flattened path in $and', () => { + // reverse key order: the nested object is flattened first, then the + // dotted leaf collides with it + expect(dotifyQuery({ user: { name: 'b' }, 'user.name': 'a' })).toEqual({ + 'user.name': 'b', + $and: [{ 'user.name': 'a' }], + }) + }) + + it('wraps colliding operators of a mixed object in $and', () => { + expect(dotifyQuery({ a: { b: 3 }, 'a.b': { $ne: 1, c: 2 } })).toEqual({ + 'a.b': 3, + 'a.b.c': 2, + $and: [{ 'a.b': { $ne: 1 } }], + }) + }) + + it('hoists a conflict from a nested object to the enclosing level', () => { + expect(dotifyQuery({ user: { 'a.b': 1, a: { b: 2 } } })).toEqual({ + 'user.a.b': 1, + $and: [{ 'user.a.b': 2 }], + }) + }) + + it('keeps a conflict inside the $or branch it came from', () => { + expect( + dotifyQuery({ $or: [{ 'user.name': 'a', user: { name: 'b' } }] }), + ).toEqual({ + $or: [{ 'user.name': 'a', $and: [{ 'user.name': 'b' }] }], + }) + }) + }) + + describe('immutability', () => { + it('returns the identical object when nothing changed', () => { + const query = { id: 1, age: { $gt: 18 }, $limit: 10 } + expect(dotifyQuery(query)).toBe(query) + }) + + it('does not mutate the input', () => { + const query = { user: { name: 'x' }, $or: [{ user: { age: 1 } }] } + const snapshot = structuredClone(query) + dotifyQuery(query) + expect(query).toEqual(snapshot) + }) + }) +}) diff --git a/src/utils/dotify-query/dotify-query.util.ts b/src/utils/dotify-query/dotify-query.util.ts new file mode 100644 index 0000000..20b4dc4 --- /dev/null +++ b/src/utils/dotify-query/dotify-query.util.ts @@ -0,0 +1,257 @@ +import type { Query } from '@feathersjs/feathers' +import { branchOperators, isPlainObject } from '../../common/index.js' +import { assignPath, dotifySortKeys, mergeAndBranches } from './dotify-keys.js' + +export type DotifyQueryPredicateOptions = { + /** the current key, e.g. `'owner'` */ + key: string + /** the full dotted path including the key, e.g. `'company.owner'` */ + path: string + /** the value at that key */ + value: any +} + +export type DotifyQueryOptions = { + /** + * Per-key override. Return `true` to descend into the value, `false` to treat + * it as a leaf value, or `undefined` to fall through to `exclude`/`include` + * and then the default heuristic. + * + * Only called for values that could be descended at all (non-empty plain + * objects), and it takes precedence over `exclude`/`include`. + */ + descend?: (options: DotifyQueryPredicateOptions) => boolean | undefined | void + /** Dotted paths that are never descended into. Matches the full `path`. */ + exclude?: string[] + /** + * If given, only these dotted paths are descended into. Matches the full + * `path`. + */ + include?: string[] +} + +type State = { changed: boolean } + +const shouldDescend = ( + options: DotifyQueryOptions, + ctx: DotifyQueryPredicateOptions, +): boolean => { + const { value } = ctx + + // only non-empty plain objects are candidates — `Date`, `RegExp`, `ObjectId`, + // class instances, arrays and primitives are always values + if (!isPlainObject(value) || Object.keys(value).length === 0) { + return false + } + + const explicit = options.descend?.(ctx) + if (typeof explicit === 'boolean') { + return explicit + } + + if (options.exclude?.includes(ctx.path)) { + return false + } + + if (options.include && !options.include.includes(ctx.path)) { + return false + } + + // an object of nothing but `$`-operators is a leaf + return Object.keys(value).some((key) => !key.startsWith('$')) +} + +const dotifyBranches = ( + branches: any[], + options: DotifyQueryOptions, + prefix: string, + state: State, +): any[] => + branches.map((branch) => + isPlainObject(branch) ? dotifyBody(branch, options, prefix, state) : branch, + ) + +const dotifyBody = ( + query: Record, + options: DotifyQueryOptions, + prefix: string, + state: State, +): Record => { + const result: Record = {} + // conditions that collided with an already-assigned path; merged into `$and` + // once every key is processed, so key order cannot clobber an existing `$and` + const conflicts: Record[] = [] + + for (const key of Object.keys(query)) { + const value = query[key] + + if (branchOperators.has(key) && Array.isArray(value)) { + result[key] = dotifyBranches(value, options, prefix, state) + continue + } + + if (key === '$not') { + result[key] = Array.isArray(value) + ? dotifyBranches(value, options, prefix, state) + : isPlainObject(value) + ? dotifyBody(value, options, prefix, state) + : value + continue + } + + if (key === '$sort' && isPlainObject(value)) { + const sort = dotifySortKeys(value) + if (sort !== value) { + state.changed = true + } + result[key] = sort + continue + } + + // `$select`, `$limit`, `$skip` and any custom operator pass through + if (key.startsWith('$')) { + result[key] = value + continue + } + + const path = prefix ? `${prefix}.${key}` : key + + if (!shouldDescend(options, { key, path, value })) { + const conflict = assignPath(result, path, value) + if (conflict) { + conflicts.push(conflict) + } + continue + } + + state.changed = true + + // operators stay on the current path, plain sub-keys are descended + const operators: Record = {} + const nested: Record = {} + for (const subKey of Object.keys(value)) { + if (subKey.startsWith('$')) { + operators[subKey] = value[subKey] + } else { + nested[subKey] = value[subKey] + } + } + + if (Object.keys(operators).length > 0) { + const conflict = assignPath(result, path, operators) + if (conflict) { + conflicts.push(conflict) + } + } + + const dotted = dotifyBody(nested, options, path, state) + for (const dottedKey of Object.keys(dotted)) { + // the sub-object's own conflicts belong to this level's implicit `$and`, + // because its keys just became keys of this level + if (dottedKey === '$and') { + conflicts.push(...dotted.$and) + continue + } + + const conflict = assignPath(result, dottedKey, dotted[dottedKey]) + if (conflict) { + conflicts.push(conflict) + } + } + } + + if (conflicts.length > 0) { + state.changed = true + mergeAndBranches(result, conflicts) + } + + return result +} + +/** + * Converts the nested properties of a Feathers query into dot notation — + * `{ user: { name: 'x' } }` becomes `{ 'user.name': 'x' }`. This is the form + * every Feathers adapter understands, so it is the direction you normally want. + * + * The conversion is query-aware rather than a generic object flatten: + * - operators (`$ne`, `$in`, ...) never become path segments + * - `$or`/`$and`/`$nor`/`$not` branches are converted individually + * - `$sort` keys are flattened, its directions are kept + * - `$select`, `$limit`, `$skip` and custom operators pass through untouched + * + * A value is only treated as a path when it is a non-empty plain object with at + * least one non-`$` key. `Date`, `RegExp`, bson `ObjectId`, class instances, + * arrays, primitives and `{}` are always values. An object that mixes operators + * and plain keys keeps its operators on the current path: + * `{ user: { $ne: null, name: 'x' } }` becomes + * `{ user: { $ne: null }, 'user.name': 'x' }`. + * + * Use `descend`, `exclude` or `include` for properties that legitimately hold an + * object value. The query is not mutated and is returned unchanged (same + * reference) when there was nothing to convert. + * + * Nothing is ever dropped when two paths collide. Deep-equal values collapse + * into one, operator objects with disjoint keys merge, and a genuine + * contradiction is wrapped in `$and` — the colliding keys were an implicit AND + * to begin with. This matches {@link addToQuery}. + * + * @example + * ```ts + * import { dotifyQuery } from 'feathers-utils/utils' + * + * dotifyQuery({ user: { name: { $ne: 'x' } } }) + * // => { 'user.name': { $ne: 'x' } } + * + * dotifyQuery({ $or: [{ user: { name: 'a' } }] }) + * // => { $or: [{ 'user.name': 'a' }] } + * + * dotifyQuery({ $sort: { user: { name: 1 } } }) + * // => { $sort: { 'user.name': 1 } } + * ``` + * + * @example + * ```ts + * // contradicting conditions for the same path are kept as an `$and` + * dotifyQuery({ 'user.name': 'a', user: { name: 'b' } }) + * // => { 'user.name': 'a', $and: [{ 'user.name': 'b' }] } + * + * // disjoint operators merge, deep-equal values collapse + * dotifyQuery({ 'user.age': { $gt: 18 }, user: { age: { $lt: 30 } } }) + * // => { 'user.age': { $gt: 18, $lt: 30 } } + * ``` + * + * @example + * ```ts + * // `meta` holds an object that should be matched by equality + * dotifyQuery({ meta: { a: 1 } }, { exclude: ['meta'] }) + * // => { meta: { a: 1 } } + * + * // depth-agnostic: never descend into a key named `meta` + * dotifyQuery(query, { + * descend: ({ key }) => (key === 'meta' ? false : undefined), + * }) + * ``` + * + * @example + * ```ts + * // normalize incoming queries for the whole service + * import { transformQuery } from 'feathers-utils/hooks' + * + * app.service('users').hooks({ before: { find: [transformQuery(dotifyQuery)] } }) + * ``` + * + * @see https://utils.feathersjs.com/utils/dotify-query.html + */ +export const dotifyQuery = ( + query: Q, + options: DotifyQueryOptions = {}, +): Q => { + if (!isPlainObject(query)) { + return query + } + + const state: State = { changed: false } + const result = dotifyBody(query, options, '', state) + + return (state.changed ? result : query) as Q +} diff --git a/src/utils/index.ts b/src/utils/index.ts index b25badf..d84f576 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -4,6 +4,7 @@ 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 './dotify-query/dotify-query.util.js' export * from './gate-params/gate-params.util.js' export * from './get-data-is-array/get-data-is-array.util.js' export * from './get-exposed-methods/get-exposed-methods.util.js' @@ -13,6 +14,7 @@ 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 './nestify-query/nestify-query.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' diff --git a/src/utils/nestify-query/nestify-query.util.md b/src/utils/nestify-query/nestify-query.util.md new file mode 100644 index 0000000..ebaf029 --- /dev/null +++ b/src/utils/nestify-query/nestify-query.util.md @@ -0,0 +1,9 @@ +--- +title: nestifyQuery +category: utils +see: + - utils/dotifyQuery + - utils/addToQuery + - utils/walkQuery + - hooks/transformQuery +--- diff --git a/src/utils/nestify-query/nestify-query.util.test.ts b/src/utils/nestify-query/nestify-query.util.test.ts new file mode 100644 index 0000000..4190cd3 --- /dev/null +++ b/src/utils/nestify-query/nestify-query.util.test.ts @@ -0,0 +1,399 @@ +import { describe, it, expect } from 'vitest' +import { nestifyQuery } from './nestify-query.util.js' +import { dotifyQuery } from '../dotify-query/dotify-query.util.js' + +describe('nestifyQuery', () => { + describe('basic conversion', () => { + it('nests a one-level dotted key', () => { + expect(nestifyQuery({ 'user.name': 'x' })).toEqual({ + user: { name: 'x' }, + }) + }) + + it('nests a multi-level dotted key', () => { + expect(nestifyQuery({ 'company.owner.name': 'x' })).toEqual({ + company: { owner: { name: 'x' } }, + }) + }) + + it('merges sibling dotted keys into one object', () => { + expect( + nestifyQuery({ 'user.name': 'a', 'user.age': { $gt: 18 } }), + ).toEqual({ user: { name: 'a', age: { $gt: 18 } } }) + }) + + it('keeps an operator object as a leaf', () => { + expect(nestifyQuery({ 'user.name': { $ne: 'x' } })).toEqual({ + user: { name: { $ne: 'x' } }, + }) + }) + + it('leaves a flat query untouched', () => { + expect(nestifyQuery({ id: 1, age: { $gt: 18 } })).toEqual({ + id: 1, + age: { $gt: 18 }, + }) + }) + + it('nests dotted keys found inside an already-nested object', () => { + expect(nestifyQuery({ user: { 'address.city': 'x' } })).toEqual({ + user: { address: { city: 'x' } }, + }) + }) + + it('never splits a path containing a $ segment', () => { + expect(nestifyQuery({ 'user.$ne': 1 } as any)).toEqual({ 'user.$ne': 1 }) + }) + }) + + describe('branch operators', () => { + it('converts inside $or', () => { + expect(nestifyQuery({ $or: [{ 'user.name': 'a' }] })).toEqual({ + $or: [{ user: { name: 'a' } }], + }) + }) + + it('converts inside $and', () => { + expect(nestifyQuery({ $and: [{ 'user.name': 'a' }, { id: 1 }] })).toEqual( + { + $and: [{ user: { name: 'a' } }, { id: 1 }], + }, + ) + }) + + it('converts inside $nor', () => { + expect(nestifyQuery({ $nor: [{ 'user.name': 'a' }] })).toEqual({ + $nor: [{ user: { name: 'a' } }], + }) + }) + + it('converts inside nested branches ($or > $and)', () => { + expect(nestifyQuery({ $or: [{ $and: [{ 'user.name': 'a' }] }] })).toEqual( + { $or: [{ $and: [{ user: { name: 'a' } }] }] }, + ) + }) + + it('leaves non-object branch entries alone', () => { + expect(nestifyQuery({ $or: [null, { 'user.name': 'a' }] })).toEqual({ + $or: [null, { user: { name: 'a' } }], + }) + }) + + it('converts $not given as an array of branches', () => { + expect(nestifyQuery({ $not: [{ 'user.name': 'a' }] } as any)).toEqual({ + $not: [{ user: { name: 'a' } }], + }) + }) + + it('converts $not given as a single sub-query', () => { + expect(nestifyQuery({ $not: { 'user.name': 'a' } } as any)).toEqual({ + $not: { user: { name: 'a' } }, + }) + }) + + it('leaves a scalar $not alone', () => { + expect(nestifyQuery({ $not: 1 } as any)).toEqual({ $not: 1 }) + }) + }) + + describe('filters', () => { + it('keeps dotted $sort keys as-is', () => { + expect(nestifyQuery({ $sort: { 'user.name': 1 } })).toEqual({ + $sort: { 'user.name': 1 }, + }) + }) + + it('flattens nested $sort keys instead of nesting them', () => { + expect(nestifyQuery({ $sort: { user: { name: -1 } } })).toEqual({ + $sort: { 'user.name': -1 }, + }) + }) + + it('leaves a non-object $sort alone', () => { + expect(nestifyQuery({ $sort: 'name' } as any)).toEqual({ $sort: 'name' }) + }) + + it('leaves $select untouched', () => { + expect(nestifyQuery({ $select: ['user.name'] })).toEqual({ + $select: ['user.name'], + }) + }) + + it('leaves $limit and $skip untouched', () => { + expect(nestifyQuery({ $limit: 10, $skip: 5 })).toEqual({ + $limit: 10, + $skip: 5, + }) + }) + + it('leaves a custom top-level operator untouched', () => { + expect(nestifyQuery({ $fuzzy: { 'a.b': 'x' } } as any)).toEqual({ + $fuzzy: { 'a.b': 'x' }, + }) + }) + }) + + describe('non-splittable values', () => { + it('leaves a Date alone', () => { + const at = new Date() + expect(nestifyQuery({ 'user.at': at })).toEqual({ user: { at } }) + }) + + it('leaves an array alone', () => { + expect(nestifyQuery({ 'user.tags': ['a'] })).toEqual({ + user: { tags: ['a'] }, + }) + }) + + it('leaves null and primitives alone', () => { + expect(nestifyQuery({ a: null, b: 1, c: false })).toEqual({ + a: null, + b: 1, + c: false, + }) + }) + + it('returns a non-object query as-is', () => { + expect(nestifyQuery(null as any)).toBe(null) + }) + }) + + describe('split predicate', () => { + it('false keeps the key as-is', () => { + expect(nestifyQuery({ 'a.b': 1 }, { split: () => false })).toEqual({ + 'a.b': 1, + }) + }) + + it('true forces the split of a $-containing path', () => { + expect( + nestifyQuery({ 'user.$ne': 1 } as any, { split: () => true }), + ).toEqual({ user: { $ne: 1 } }) + }) + + it('undefined falls through to the default heuristic', () => { + expect(nestifyQuery({ 'a.b': 1 }, { split: () => undefined })).toEqual({ + a: { b: 1 }, + }) + }) + + it('receives key, path and value', () => { + const seen: { key: string; path: string; value: any }[] = [] + nestifyQuery( + { user: { 'address.city': 'x' } }, + { + split: (options) => { + seen.push({ ...options }) + return undefined + }, + }, + ) + expect(seen).toEqual([ + { key: 'address.city', path: 'user.address.city', value: 'x' }, + ]) + }) + + it('is not called for keys without a dot', () => { + let calls = 0 + nestifyQuery( + { id: 1, user: { name: 'x' } }, + { + split: () => { + calls++ + return undefined + }, + }, + ) + expect(calls).toBe(0) + }) + + it('takes precedence over exclude', () => { + expect( + nestifyQuery({ 'a.b': 1 }, { exclude: ['a.b'], split: () => true }), + ).toEqual({ a: { b: 1 } }) + }) + + it('takes precedence over include', () => { + expect( + nestifyQuery({ 'a.b': 1 }, { include: ['other'], split: () => true }), + ).toEqual({ a: { b: 1 } }) + }) + }) + + describe('exclude / include', () => { + it('excludes a top-level key', () => { + expect( + nestifyQuery({ 'x.y': 1, 'a.b': 2 }, { exclude: ['x.y'] }), + ).toEqual({ 'x.y': 1, a: { b: 2 } }) + }) + + it('excludes a deep path', () => { + expect( + nestifyQuery( + { user: { 'a.b': 1, 'c.d': 2 } }, + { exclude: ['user.a.b'] }, + ), + ).toEqual({ user: { 'a.b': 1, c: { d: 2 } } }) + }) + + it('include restricts the conversion to the listed paths', () => { + expect( + nestifyQuery({ 'a.b': 1, 'x.y': 2 }, { include: ['a.b'] }), + ).toEqual({ a: { b: 1 }, 'x.y': 2 }) + }) + }) + + describe('collisions', () => { + it('merges a dotted key into an existing object', () => { + expect(nestifyQuery({ 'user.name': 'a', user: { age: 1 } })).toEqual({ + user: { name: 'a', age: 1 }, + }) + }) + + it('collapses deep-equal values for the same path', () => { + expect(nestifyQuery({ 'user.name': 'a', user: { name: 'a' } })).toEqual({ + user: { name: 'a' }, + }) + }) + + it('keeps the dotted key when the path is blocked by a scalar', () => { + // no `$and` needed — the dotted key is already a valid condition + const query = { user: 5, 'user.name': 'a' } + expect(nestifyQuery(query)).toEqual({ user: 5, 'user.name': 'a' }) + expect(nestifyQuery(query)).toBe(query) + }) + + it('keeps the dotted key when a deeper segment is blocked', () => { + expect(nestifyQuery({ 'a.b': 5, 'a.b.c': 1 })).toEqual({ + a: { b: 5 }, + 'a.b.c': 1, + }) + }) + + it('wraps contradictory values for the same path in $and', () => { + expect(nestifyQuery({ 'user.name': 'a', user: { name: 'b' } })).toEqual({ + user: { name: 'a' }, + $and: [{ user: { name: 'b' } }], + }) + }) + + it('wraps a dotted key colliding with an existing leaf in $and', () => { + // reverse key order: `user` is nested first, so the split of `user.name` + // is the side that hits the conflict + expect(nestifyQuery({ user: { name: 'b' }, 'user.name': 'a' })).toEqual({ + user: { name: 'b' }, + $and: [{ user: { name: 'a' } }], + }) + }) + + it('appends to an existing $and regardless of key order', () => { + const expected = { + user: { name: 'a' }, + $and: [{ x: 1 }, { user: { name: 'b' } }], + } + + expect( + nestifyQuery({ + $and: [{ x: 1 }], + 'user.name': 'a', + user: { name: 'b' }, + }), + ).toEqual(expected) + + expect( + nestifyQuery({ + 'user.name': 'a', + user: { name: 'b' }, + $and: [{ x: 1 }], + }), + ).toEqual(expected) + }) + + it('does not duplicate a branch already present in $and', () => { + expect( + nestifyQuery({ + $and: [{ user: { name: 'b' } }], + 'user.name': 'a', + user: { name: 'b' }, + }), + ).toEqual({ user: { name: 'a' }, $and: [{ user: { name: 'b' } }] }) + }) + + it('hoists a conflict out of a property value, re-keyed', () => { + expect(nestifyQuery({ user: { 'a.b': 1, a: { b: 2 } } })).toEqual({ + user: { a: { b: 1 } }, + $and: [{ user: { a: { b: 2 } } }], + }) + }) + + it('hoists a conflict out of a dotted property value, re-keyed', () => { + expect(nestifyQuery({ 'x.user': { 'a.b': 1, a: { b: 2 } } })).toEqual({ + x: { user: { a: { b: 1 } } }, + $and: [{ x: { user: { a: { b: 2 } } } }], + }) + }) + + it('keeps a conflict inside the $or branch it came from', () => { + expect( + nestifyQuery({ $or: [{ 'user.name': 'a', user: { name: 'b' } }] }), + ).toEqual({ + $or: [{ user: { name: 'a' }, $and: [{ user: { name: 'b' } }] }], + }) + }) + }) + + describe('immutability', () => { + it('returns the identical object when nothing changed', () => { + const query = { id: 1, user: { name: 'a' }, $limit: 10 } + expect(nestifyQuery(query)).toBe(query) + }) + + it('does not mutate the input', () => { + const query = { + 'user.name': 'a', + user: { age: 1 }, + $or: [{ 'a.b': 1 }], + } + const snapshot = structuredClone(query) + nestifyQuery(query) + expect(query).toEqual(snapshot) + }) + }) + + describe('round-trip with dotifyQuery', () => { + const corpus = [ + { id: 1 }, + { 'user.name': 'x' }, + { user: { name: 'x' } }, + { 'user.name': { $ne: 'x' } }, + { 'company.owner.name': 'x', 'company.id': 2 }, + { $or: [{ 'user.name': 'a' }, { 'user.age': { $gt: 18 } }] }, + { $and: [{ $or: [{ 'a.b.c': 1 }] }, { d: 2 }] }, + { $sort: { 'user.name': 1 }, $select: ['user.name'], $limit: 10 }, + { at: new Date(0), tags: ['a'], empty: {} }, + { 'user.name': 'a', user: { name: 'b' } }, + { user: 5, 'user.name': 'a' }, + { 'a.b': 5, 'a.b.c': 1 }, + ] + + it.each(corpus)('dotify(nestify(q)) === dotify(q) for %j', (query) => { + expect(dotifyQuery(nestifyQuery(query as any))).toEqual( + dotifyQuery(query as any), + ) + }) + + it('dotify is idempotent', () => { + for (const query of corpus) { + const once = dotifyQuery(query as any) + expect(dotifyQuery(once)).toEqual(once) + } + }) + + it('nestify is idempotent', () => { + for (const query of corpus) { + const once = nestifyQuery(query as any) + expect(nestifyQuery(once)).toEqual(once) + } + }) + }) +}) diff --git a/src/utils/nestify-query/nestify-query.util.ts b/src/utils/nestify-query/nestify-query.util.ts new file mode 100644 index 0000000..d37b8b2 --- /dev/null +++ b/src/utils/nestify-query/nestify-query.util.ts @@ -0,0 +1,256 @@ +import type { Query } from '@feathersjs/feathers' +import { branchOperators, isPlainObject } from '../../common/index.js' +import { + assignPath, + dotifySortKeys, + mergeAndBranches, + nest, + setNested, +} from '../dotify-query/dotify-keys.js' + +export type NestifyQueryPredicateOptions = { + /** the current — possibly dotted — key, e.g. `'owner.name'` */ + key: string + /** the full dotted path including the key, e.g. `'company.owner.name'` */ + path: string + /** the value at that key */ + value: any +} + +export type NestifyQueryOptions = { + /** + * Per-key override. Return `true` to split the key into nested objects, + * `false` to keep it as-is, or `undefined` to fall through to + * `exclude`/`include` and then the default heuristic. + * + * Only called for keys that actually contain a `.`, and it takes precedence + * over `exclude`/`include`. + */ + split?: (options: NestifyQueryPredicateOptions) => boolean | undefined | void + /** Dotted paths that are never split. Matches the full `path`. */ + exclude?: string[] + /** If given, only these dotted paths are split. Matches the full `path`. */ + include?: string[] +} + +type State = { changed: boolean } + +const shouldSplit = ( + options: NestifyQueryOptions, + ctx: NestifyQueryPredicateOptions, +): boolean => { + const explicit = options.split?.(ctx) + if (typeof explicit === 'boolean') { + return explicit + } + + if (options.exclude?.includes(ctx.path)) { + return false + } + + if (options.include && !options.include.includes(ctx.path)) { + return false + } + + // defensive: a path containing an operator segment is never split + return !ctx.key.split('.').some((segment) => segment.startsWith('$')) +} + +const nestifyBranches = ( + branches: any[], + options: NestifyQueryOptions, + prefix: string, + state: State, +): any[] => + branches.map((branch) => + isPlainObject(branch) + ? nestifyBody(branch, options, prefix, state) + : branch, + ) + +const nestifyBody = ( + query: Record, + options: NestifyQueryOptions, + prefix: string, + state: State, +): Record => { + const result: Record = {} + // conditions that collided with an already-assigned key; merged into `$and` + // once every key is processed, so key order cannot clobber an existing `$and` + const conflicts: Record[] = [] + + for (const key of Object.keys(query)) { + const value = query[key] + + if (branchOperators.has(key) && Array.isArray(value)) { + result[key] = nestifyBranches(value, options, prefix, state) + continue + } + + if (key === '$not') { + result[key] = Array.isArray(value) + ? nestifyBranches(value, options, prefix, state) + : isPlainObject(value) + ? nestifyBody(value, options, prefix, state) + : value + continue + } + + // `$sort` stays in dot notation — that is the only form adapters understand + if (key === '$sort' && isPlainObject(value)) { + const sort = dotifySortKeys(value) + if (sort !== value) { + state.changed = true + } + result[key] = sort + continue + } + + // `$select`, `$limit`, `$skip` and any custom operator pass through + if (key.startsWith('$')) { + result[key] = value + continue + } + + const path = prefix ? `${prefix}.${key}` : key + const segments = key.split('.') + const split = + segments.length > 1 && shouldSplit(options, { key, path, value }) + + // a plain object value may hold dotted keys of its own; an object of nothing + // but operators is a leaf + let nestedValue = value + let innerAnd: Record[] = [] + if ( + isPlainObject(value) && + Object.keys(value).some((subKey) => !subKey.startsWith('$')) + ) { + const inner = nestifyBody(value, options, path, state) + + // a `$and` produced inside the value belongs to this level — a logical + // operator cannot sit inside a property + if (Array.isArray(inner.$and)) { + const { $and, ...rest } = inner + innerAnd = $and + nestedValue = rest + } else { + nestedValue = inner + } + } + + let didSplit = false + let conflict: Record | undefined + + if (split) { + // `setNested` may report back that it did not split after all, because the + // path was blocked and the dotted key was kept instead + const outcome = setNested(result, segments, nestedValue) + didSplit = outcome.split + conflict = outcome.conflict + + if (didSplit) { + state.changed = true + } + } else { + conflict = assignPath(result, key, nestedValue) + } + + if (conflict) { + conflicts.push(conflict) + } + + // re-key the hoisted branches to wherever the value actually ended up + for (const branch of innerAnd) { + conflicts.push(didSplit ? nest(segments, branch) : { [key]: branch }) + } + } + + if (conflicts.length > 0) { + state.changed = true + mergeAndBranches(result, conflicts) + } + + return result +} + +/** + * Converts the dot-notation properties of a Feathers query into nested objects — + * `{ 'user.name': 'x' }` becomes `{ user: { name: 'x' } }`. This is the inverse + * of {@link dotifyQuery}. + * + * The conversion is query-aware rather than a generic object unflatten: + * - `$or`/`$and`/`$nor`/`$not` branches are converted individually + * - `$sort` keys are kept in dot notation (nested ones are flattened), because + * that is the only form the Feathers adapters understand + * - `$select`, `$limit`, `$skip` and custom operators pass through untouched — + * `$select` holds paths as *values*, not as keys + * - a path containing a `$`-prefixed segment is never split + * + * Use `split`, `exclude` or `include` for keys whose dots are meaningful data. + * The query is not mutated and is returned unchanged (same reference) when there + * was nothing to convert. + * + * Nothing is ever dropped when two keys collide. Deep-equal values collapse into + * one, objects with disjoint keys merge, and a genuine contradiction is wrapped + * in `$and`, since the colliding keys were an implicit AND to begin with — this + * matches {@link addToQuery}. A key whose path is blocked by a non-object value + * needs no `$and` at all: it simply stays in dot notation, which is already a + * valid condition. + * + * **Caveat:** this direction is best effort and not semantics-preserving on + * MongoDB, where `{ user: { name: 'x' } }` means *document equality* while + * `{ 'user.name': 'x' }` means a *subfield match*. `dotifyQuery` is the reliable + * direction; reach for `nestifyQuery` when a consumer genuinely needs the nested + * shape. + * + * @example + * ```ts + * import { nestifyQuery } from 'feathers-utils/utils' + * + * nestifyQuery({ 'user.name': { $ne: 'x' } }) + * // => { user: { name: { $ne: 'x' } } } + * + * nestifyQuery({ 'user.name': 'a', 'user.age': { $gt: 18 } }) + * // => { user: { name: 'a', age: { $gt: 18 } } } + * + * nestifyQuery({ $sort: { 'user.name': 1 } }) + * // => { $sort: { 'user.name': 1 } } (unchanged on purpose) + * ``` + * + * @example + * ```ts + * // the dots in this key are data, not a path + * nestifyQuery({ 'x.y': 1, 'a.b': 2 }, { exclude: ['x.y'] }) + * // => { 'x.y': 1, a: { b: 2 } } + * ``` + * + * @example + * ```ts + * // sibling paths merge into one object + * nestifyQuery({ 'user.name': 'a', user: { age: 1 } }) + * // => { user: { name: 'a', age: 1 } } + * + * // `user` is not an object here, so the dotted key stays as it is + * nestifyQuery({ user: 5, 'user.name': 'a' }) + * // => { user: 5, 'user.name': 'a' } (unchanged) + * + * // a real contradiction still needs an `$and` + * nestifyQuery({ 'user.name': 'a', user: { name: 'b' } }) + * // => { user: { name: 'a' }, $and: [{ user: { name: 'b' } }] } + * ``` + * + * @see https://utils.feathersjs.com/utils/nestify-query.html + */ +export const nestifyQuery = ( + query: Q, + options: NestifyQueryOptions = {}, +): Q => { + if (!isPlainObject(query)) { + return query + } + + const state: State = { changed: false } + const result = nestifyBody(query, options, '', state) + + return (state.changed ? result : query) as Q +} diff --git a/test/index.test.ts b/test/index.test.ts index 45513e3..bc28aeb 100755 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -52,6 +52,7 @@ const utils = [ 'chunkFind', 'contextToJson', 'defineHooks', + 'dotifyQuery', 'gateParams', 'getDataIsArray', 'getExposedMethods', @@ -61,6 +62,7 @@ const utils = [ 'mergeQuery', 'mutateData', 'mutateResult', + 'nestifyQuery', 'patchBatch', 'queryDefaults', 'queryHasProperty', From 24390c608a1202c1b7c622771daa1f57ccf98efc Mon Sep 17 00:00:00 2001 From: Frederik Schmatz Date: Thu, 20 Aug 2026 07:56:59 +0200 Subject: [PATCH 2/2] chore: scope test and lint discovery to src and test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `test.include` was never set, so vitest fell back to its project-wide default (`**/*.{test,spec}.*`), which only skips `node_modules` and `dist`. The globs that are set cover something else: `includeSource` is for in-source tests and `coverage.include` only bounds the coverage scope. As a result, stray checkouts — e.g. a git worktree under `.claude/` — had their tests and type tests picked up and run. ESLint had the same problem from the other side: `eslint .` walked into them, and since they are outside `tsconfig.eslint.json` every file came back as a parser error. Scope the discovery instead of blacklisting one directory, so future worktrees and temporary clones are covered too. Co-Authored-By: Claude Opus 5 --- eslint.config.mjs | 5 +++++ vite.config.ts | 6 ++++++ 2 files changed, 11 insertions(+) diff --git a/eslint.config.mjs b/eslint.config.mjs index 15f7047..f636c98 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -4,6 +4,11 @@ export default config( { tsconfig: { path: './tsconfig.eslint.json' }, }, + // stray checkouts (e.g. git worktrees under `.claude/`) are outside + // `tsconfig.eslint.json`, so linting them only yields parser errors + { + ignores: ['.claude/**'], + }, // additional rules for source files { files: ['src/**/*.ts'], diff --git a/vite.config.ts b/vite.config.ts index 89cd4bb..fa28eb5 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -3,9 +3,15 @@ import { defineConfig } from 'vitest/config' export default defineConfig({ test: { globals: true, + // scope discovery to the real source trees. Without this, `test.include` + // falls back to vitest's project-wide default, which only skips + // `node_modules` and `dist` — so stray checkouts (e.g. git worktrees under + // `.claude/`) would get their tests and type tests run as well. + include: ['{src,test}/**/*.test.ts'], includeSource: ['src/**/*.{js,ts}'], typecheck: { enabled: true, + include: ['{src,test}/**/*.test-d.ts'], }, coverage: { provider: 'v8',