Skip to content

feat: new utils dotifyQuery & nestifyQuery - #45

Open
fratzinger wants to merge 2 commits into
mainfrom
feat/dotify-nestify-query
Open

feat: new utils dotifyQuery & nestifyQuery#45
fratzinger wants to merge 2 commits into
mainfrom
feat/dotify-nestify-query

Conversation

@fratzinger

Copy link
Copy Markdown
Member

Queries arrive in both dot notation ({ 'user.name': 'x' }) and nested form ({ user: { name: 'x' } }) depending on where they come from — client, form builder, stored filter, hand-written server code. Adapters only reliably understand the dot form, so every call site had to normalize it by hand. These two utils do the conversion in both directions.

Query-aware, not a generic flatten

A naive flatten/unflatten is wrong here:

  • operators ($ne, $in, …) never become path segments
  • $or/$and/$nor/$not are converted per branch, not as $or.0.…
  • $sort keys stay in dot notation in both directions — deliberately asymmetric, since that is the only form adapters understand
  • $select holds paths as values, so it passes through untouched, as do $limit, $skip and custom operators
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 } }

nestifyQuery({ 'user.name': 'a', 'user.age': { $gt: 18 } })
// { user: { name: 'a', age: { $gt: 18 } } }

dotifyQuery is the reliable direction. nestifyQuery is documented as best effort: on MongoDB { user: { name: 'x' } } means document equality while { 'user.name': 'x' } means a subfield match.

Pluggable, because "value vs. path" is not always decidable

By default a value is a path only if it is a non-empty plain object with at least one non-$ key (Date, RegExp, ObjectId, class instances, arrays, {} are values). A field may legitimately hold an object matched by equality, so both take a per-key predicate plus declarative shortcuts:

dotifyQuery(query, { exclude: ['meta'] })
dotifyQuery(query, { descend: ({ key }) => (key === 'meta' ? false : undefined) })
nestifyQuery(query, { split: ({ key }) =>  })

The predicate wins over include/exclude, which match the full dotted path; returning undefined falls through to the default heuristic. The predicate is only called where there is an actual decision to make.

Collisions never lose data

Following addToQuery, which already documents wrapping conflicting conditions in $and:

case result
deep-equal values (dequal) collapse into one
objects with disjoint keys merged — {$gt: 18} + {$lt: 30}
genuine contradiction $and branch, deduped via dedupeBranches
nestifyQuery: path blocked by a non-object key simply stays in dot notation — no $and needed
dotifyQuery({ 'user.name': 'a', user: { name: 'b' } })
// { 'user.name': 'a', $and: [{ 'user.name': 'b' }] }

nestifyQuery({ user: 5, 'user.name': 'a' })   // unchanged

Two details worth a look in review:

  • conflicts are collected per level and merged after the key loop, so an $and in the input cannot be clobbered by key order
  • a $and produced inside a property value is hoisted to the enclosing level and re-keyed — a logical operator cannot sit inside a property

Second commit: test/lint discovery

Independent of the feature, and reviewable on its own. test.include was never set, so vitest fell back to its project-wide default and picked up tests from stray checkouts (a git worktree under .claude/); ESLint walked into the same directories and reported every file as a parser error, since they are outside tsconfig.eslint.json. The fix scopes discovery to src/test rather than blacklisting one directory. Happy to split this out if you would rather keep the PR to the feature.

Verification

npm test passes (exit 0): 1213 tests, 0 lint errors, clean typecheck, coverage 96.64% statements / 92.52% branches — 100% statements/branches/functions on all four new source files. Both .md docs pages are discovered with working see: cross-links, and the tsdown build is clean.

🤖 Generated with Claude Code

Frederik Schmatz and others added 2 commits August 20, 2026 07:56
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 <noreply@anthropic.com>
`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 <noreply@anthropic.com>
@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying feathers-utils with  Cloudflare Pages  Cloudflare Pages

Latest commit: 24390c6
Status: ✅  Deploy successful!
Preview URL: https://443852f5.feathers-utils.pages.dev
Branch Preview URL: https://feat-dotify-nestify-query.feathers-utils.pages.dev

View logs

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant