Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down
1 change: 1 addition & 0 deletions src/common/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
16 changes: 16 additions & 0 deletions src/common/query-operators.ts
Original file line number Diff line number Diff line change
@@ -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'])
})
})
}
159 changes: 159 additions & 0 deletions src/utils/dotify-query/dotify-keys.ts
Original file line number Diff line number Diff line change
@@ -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 = <T extends Record<string, any>>(sort: T): T => {
let changed = false
const result: Record<string, any> = {}

const walk = (node: Record<string, any>, 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<string, any> => {
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<string, any>,
key: string,
value: any,
): Record<string, any> | 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<string, any>,
branches: Record<string, any>[],
): 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<string, any>
}

/**
* 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<string, any>,
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,
}
}
9 changes: 9 additions & 0 deletions src/utils/dotify-query/dotify-query.util.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
title: dotifyQuery
category: utils
see:
- utils/nestifyQuery
- utils/addToQuery
- utils/walkQuery
- hooks/transformQuery
---
Loading
Loading