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/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', 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',