From bfe981e88be05d0b825f916975ac02172305c331 Mon Sep 17 00:00:00 2001 From: fratzinger <22286818+fratzinger@users.noreply.github.com> Date: Wed, 24 Jun 2026 10:04:32 +0200 Subject: [PATCH] feat!: new query helpers and muteEvent hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - muteEvent(options?): hook that suppresses the service event by setting context.event to null, optionally gated by a when boolean/predicate (before/after/around). - queryHasProperty(query, name | names): util to detect a property anywhere in a query, including nested in $and/$or/$nor. - walkQuery: walker options gain stop() to end traversal early. - queryDefaults(query, defaults): util that adds default query fields only for fields the query does not already constrain (nested-aware) — the query counterpart of the defaults data transformer. - setQueryDefaults(defaults): before/around hook applying queryDefaults to context.params.query (e.g. filter out template rows by default). - softDelete: new allowQueryOverride option; its query filter now goes through queryDefaults. BREAKING CHANGE: by default softDelete no longer always appends deletedQuery. When the incoming query already references a deletedQuery field (e.g. deletedAt, including nested in $and/$or/$nor), that filter is now skipped so callers can read soft-deleted rows; remove still soft-deletes. Set allowQueryOverride: false to restore the previous always-enforce behavior. --- src/hooks/index.ts | 2 + src/hooks/mute-event/mute-event.hook.md | 9 ++ src/hooks/mute-event/mute-event.hook.test.ts | 118 ++++++++++++++++++ src/hooks/mute-event/mute-event.hook.ts | 56 +++++++++ .../set-query-defaults.hook.md | 14 +++ .../set-query-defaults.hook.test.ts | 58 +++++++++ .../set-query-defaults.hook.ts | 37 ++++++ .../soft-delete/soft-delete.hook.test.ts | 67 ++++++++++ src/hooks/soft-delete/soft-delete.hook.ts | 20 ++- src/utils/index.ts | 2 + .../query-defaults/query-defaults.util.md | 9 ++ .../query-defaults.util.test.ts | 49 ++++++++ .../query-defaults/query-defaults.util.ts | 39 ++++++ .../query-has-property.util.md | 6 + .../query-has-property.util.test.ts | 62 +++++++++ .../query-has-property.util.ts | 37 ++++++ src/utils/walk-query/walk-query.util.test.ts | 45 +++++++ src/utils/walk-query/walk-query.util.ts | 46 ++++++- test/index.test.ts | 4 + 19 files changed, 674 insertions(+), 6 deletions(-) create mode 100644 src/hooks/mute-event/mute-event.hook.md create mode 100644 src/hooks/mute-event/mute-event.hook.test.ts create mode 100644 src/hooks/mute-event/mute-event.hook.ts create mode 100644 src/hooks/set-query-defaults/set-query-defaults.hook.md create mode 100644 src/hooks/set-query-defaults/set-query-defaults.hook.test.ts create mode 100644 src/hooks/set-query-defaults/set-query-defaults.hook.ts create mode 100644 src/utils/query-defaults/query-defaults.util.md create mode 100644 src/utils/query-defaults/query-defaults.util.test.ts create mode 100644 src/utils/query-defaults/query-defaults.util.ts create mode 100644 src/utils/query-has-property/query-has-property.util.md create mode 100644 src/utils/query-has-property/query-has-property.util.test.ts create mode 100644 src/utils/query-has-property/query-has-property.util.ts diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 57ad94f..3bda818 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -9,6 +9,7 @@ export * from './disable-pagination/disable-pagination.hook.js' export * from './disallow/disallow.hook.js' export * from './iff-else/iff-else.hook.js' export * from './iff/iff.hook.js' +export * from './mute-event/mute-event.hook.js' export * from './on-delete/on-delete.hook.js' export * from './params-for-server/params-for-server.hook.js' export * from './params-from-client/params-from-client.hook.js' @@ -16,6 +17,7 @@ export * from './prevent-changes/prevent-changes.hook.js' export * from './rate-limit/rate-limit.hook.js' export * from './set-data/set-data.hook.js' export * from './set-field/set-field.hook.js' +export * from './set-query-defaults/set-query-defaults.hook.js' export * from './set-result/set-result.hook.js' export * from './set-slug/set-slug.hook.js' export * from './skippable/skippable.hook.js' diff --git a/src/hooks/mute-event/mute-event.hook.md b/src/hooks/mute-event/mute-event.hook.md new file mode 100644 index 0000000..080d390 --- /dev/null +++ b/src/hooks/mute-event/mute-event.hook.md @@ -0,0 +1,9 @@ +--- +title: muteEvent +category: hooks +hook: + type: ["before", "after", "around"] + method: ["all"] + multi: true + methods: ["all"] +--- diff --git a/src/hooks/mute-event/mute-event.hook.test.ts b/src/hooks/mute-event/mute-event.hook.test.ts new file mode 100644 index 0000000..2828df3 --- /dev/null +++ b/src/hooks/mute-event/mute-event.hook.test.ts @@ -0,0 +1,118 @@ +import type { HookContext } from '@feathersjs/feathers' +import { feathers } from '@feathersjs/feathers' +import { MemoryService } from '@feathersjs/memory' +import { muteEvent } from './mute-event.hook.js' + +type User = { id: number; name: string } + +const setup = (hooks: any[]) => { + const app = feathers<{ users: MemoryService> }>() + app.use('users', new MemoryService({ id: 'id', startId: 1, multi: true })) + app.service('users').hooks({ around: { all: hooks } }) + return app +} + +const tick = () => new Promise((resolve) => setTimeout(resolve, 10)) + +describe('muteEvent', () => { + describe('unit', () => { + it('sets context.event to null', async () => { + const context = { event: 'created' } as unknown as HookContext + await muteEvent()(context) + expect(context.event).toBe(null) + }) + + it('does nothing when `when` is false', async () => { + const context = { event: 'created' } as unknown as HookContext + await muteEvent({ when: false })(context) + expect(context.event).toBe('created') + }) + + it('respects a sync predicate', async () => { + const muted = { event: 'created' } as unknown as HookContext + await muteEvent({ when: () => true })(muted) + expect(muted.event).toBe(null) + + const kept = { event: 'created' } as unknown as HookContext + await muteEvent({ when: () => false })(kept) + expect(kept.event).toBe('created') + }) + + it('respects an async predicate', async () => { + const context = { event: 'created' } as unknown as HookContext + await muteEvent({ when: async () => true })(context) + expect(context.event).toBe(null) + }) + + it('calls next when provided', async () => { + const context = { event: 'created' } as unknown as HookContext + let called = false + await muteEvent()(context, async () => { + called = true + }) + expect(called).toBe(true) + }) + }) + + describe('integration', () => { + it('suppresses the created event', async () => { + const app = setup([muteEvent()]) + let fired = false + app.service('users').on('created', () => { + fired = true + }) + + await app.service('users').create({ name: 'test' }) + await tick() + + expect(fired).toBe(false) + }) + + it('emits the event without the hook (control)', async () => { + const app = setup([]) + let fired = false + app.service('users').on('created', () => { + fired = true + }) + + await app.service('users').create({ name: 'test' }) + await tick() + + expect(fired).toBe(true) + }) + + it('emits the event when `when` is false', async () => { + const app = setup([muteEvent({ when: false })]) + let fired = false + app.service('users').on('created', () => { + fired = true + }) + + await app.service('users').create({ name: 'test' }) + await tick() + + expect(fired).toBe(true) + }) + + it('mutes conditionally via a predicate', async () => { + const app = setup([ + muteEvent({ when: (context) => context.method === 'create' }), + ]) + let created = false + let patched = false + app.service('users').on('created', () => { + created = true + }) + app.service('users').on('patched', () => { + patched = true + }) + + const user = await app.service('users').create({ name: 'test' }) + await app.service('users').patch(user.id, { name: 'changed' }) + await tick() + + expect(created).toBe(false) + expect(patched).toBe(true) + }) + }) +}) diff --git a/src/hooks/mute-event/mute-event.hook.ts b/src/hooks/mute-event/mute-event.hook.ts new file mode 100644 index 0000000..c2dd060 --- /dev/null +++ b/src/hooks/mute-event/mute-event.hook.ts @@ -0,0 +1,56 @@ +import type { HookContext, NextFunction } from '@feathersjs/feathers' +import type { PredicateFn } from '../../types.js' + +export type MuteEventOptions = { + /** + * Only mute when this is truthy. Can be a boolean or a predicate that + * receives the `HookContext`. Defaults to always muting. + * + * @example isProvider('server') + */ + when?: boolean | PredicateFn +} + +/** + * Suppresses the service event for the current call by setting `context.event` + * to `null`. Feathers emits the standard `created`/`updated`/`patched`/`removed` + * event (the value of `context.event`) after the method runs; setting it to + * `null` prevents that emission so real-time subscribers and channels are not + * notified. + * + * Useful for seeding, migrations and internal syncs that should not trigger + * downstream listeners. Works as a `before`, `after` or `around` hook. + * + * @example + * ```ts + * import { muteEvent } from 'feathers-utils/hooks' + * import { isProvider } from 'feathers-utils/predicates' + * + * app.service('users').hooks({ + * before: { + * all: [muteEvent()], // mute every call + * create: [muteEvent({ when: isProvider('server') })], // only server calls + * } + * }) + * ``` + * + * @see https://utils.feathersjs.com/hooks/mute-event.html + */ +export const muteEvent = ( + options?: MuteEventOptions, +) => { + const when = options?.when + + return async (context: H, next?: NextFunction): Promise => { + const should = + typeof when === 'function' ? await when(context) : (when ?? true) + + if (should) { + context.event = null + } + + if (next) { + await next() + } + } +} diff --git a/src/hooks/set-query-defaults/set-query-defaults.hook.md b/src/hooks/set-query-defaults/set-query-defaults.hook.md new file mode 100644 index 0000000..3c4ae94 --- /dev/null +++ b/src/hooks/set-query-defaults/set-query-defaults.hook.md @@ -0,0 +1,14 @@ +--- +title: setQueryDefaults +category: hooks +hook: + type: ["before", "around"] + method: ["find", "get", "update", "patch", "remove"] + multi: true +see: + - utils/query-defaults + - utils/query-has-property + - utils/add-to-query + - utils/walk-query + - hooks/soft-delete +--- diff --git a/src/hooks/set-query-defaults/set-query-defaults.hook.test.ts b/src/hooks/set-query-defaults/set-query-defaults.hook.test.ts new file mode 100644 index 0000000..021a909 --- /dev/null +++ b/src/hooks/set-query-defaults/set-query-defaults.hook.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect } from 'vitest' +import type { HookContext } from '@feathersjs/feathers' +import { setQueryDefaults } from './set-query-defaults.hook.js' + +const ctx = (query?: any): HookContext => + ({ type: 'before', method: 'find', params: { query } }) as any + +describe('setQueryDefaults', () => { + it('adds the default when the field is absent', () => { + const context = ctx({ status: 'active' }) + setQueryDefaults({ isTemplate: false })(context) + expect(context.params.query).toEqual({ + status: 'active', + isTemplate: false, + }) + }) + + it('adds the default when there is no query at all', () => { + const context = ctx(undefined) + setQueryDefaults({ isTemplate: false })(context) + expect(context.params.query).toEqual({ isTemplate: false }) + }) + + it('does not override when the field is already set', () => { + const context = ctx({ isTemplate: true }) + setQueryDefaults({ isTemplate: false })(context) + expect(context.params.query).toEqual({ isTemplate: true }) + }) + + it('does not override when the field is referenced nested in $or', () => { + const context = ctx({ $or: [{ isTemplate: true }, { foo: 1 }] }) + setQueryDefaults({ isTemplate: false })(context) + expect(context.params.query).toEqual({ + $or: [{ isTemplate: true }, { foo: 1 }], + }) + }) + + it('applies multiple defaults independently (per-field)', () => { + const context = ctx({ a: 1 }) + setQueryDefaults({ isTemplate: false, archived: false })(context) + expect(context.params.query).toEqual({ + a: 1, + isTemplate: false, + archived: false, + }) + }) + + it('calls next when used as an around hook', async () => { + const context = ctx({}) + let called = false + const next = async () => { + called = true + } + await setQueryDefaults({ isTemplate: false })(context, next) + expect(called).toBe(true) + expect(context.params.query).toEqual({ isTemplate: false }) + }) +}) diff --git a/src/hooks/set-query-defaults/set-query-defaults.hook.ts b/src/hooks/set-query-defaults/set-query-defaults.hook.ts new file mode 100644 index 0000000..0631f09 --- /dev/null +++ b/src/hooks/set-query-defaults/set-query-defaults.hook.ts @@ -0,0 +1,37 @@ +import type { HookContext, NextFunction, Query } from '@feathersjs/feathers' +import { queryDefaults } from '../../utils/query-defaults/query-defaults.util.js' + +/** + * Adds default properties to `context.params.query` for fields the incoming query does + * not already constrain (including fields referenced nested in `$and`/`$or`/`$nor`). + * The query equivalent of the `defaults` transformer: e.g. hide template rows by default + * while still letting callers opt in via `{ isTemplate: true }`. This is the same pattern + * `softDelete` uses to filter out deleted rows. Works as a `before` or `around` hook. + * + * @example + * ```ts + * import { setQueryDefaults } from 'feathers-utils/hooks' + * + * app.service('posts').hooks({ + * before: { all: [setQueryDefaults({ isTemplate: false })] }, + * }) + * // find() => filters out templates + * // find({ query: { isTemplate: true } }) => caller keeps control + * ``` + * + * @see https://utils.feathersjs.com/hooks/set-query-defaults.html + */ +export const setQueryDefaults = ( + defaults: Query, +) => { + function hook(context: H): void + function hook(context: H, next: NextFunction): Promise + function hook(context: H, next?: NextFunction): void | Promise { + context.params.query = queryDefaults(context.params.query, defaults) + + if (next) return next() + + return + } + return hook +} diff --git a/src/hooks/soft-delete/soft-delete.hook.test.ts b/src/hooks/soft-delete/soft-delete.hook.test.ts index 99c5982..1bbddcf 100755 --- a/src/hooks/soft-delete/soft-delete.hook.test.ts +++ b/src/hooks/soft-delete/soft-delete.hook.test.ts @@ -225,4 +225,71 @@ describe('softDelete', () => { }), ).toExtend>>() }) + + describe('allowQueryOverride', () => { + async function setupWith(extra: { allowQueryOverride?: boolean } = {}) { + const app = feathers().use( + '/users', + new MemoryService({ multi: true, id: 'id' }), + ) + const userService: any = app.service('users') + userService.hooks({ + around: { + all: [ + softDelete({ + deletedQuery: { deletedAt: null }, + removeData: { deletedAt: new Date() }, + ...extra, + }), + ], + }, + }) + await userService.create([ + { name: 'active', key: 'a', deletedAt: null }, + { name: 'deleted', key: 'a', deletedAt: new Date() }, + ]) + return userService + } + + it('returns deleted items when the query references the field (opt-out default)', async () => { + const userService = await setupWith() + + const users = await userService.find({ + query: { deletedAt: { $ne: null } }, + }) + + assert.strictEqual(users.length, 1) + assert.strictEqual(users[0].name, 'deleted') + }) + + it('detects the field nested in $or', async () => { + const userService = await setupWith() + + const users = await userService.find({ + query: { $or: [{ deletedAt: { $ne: null } }] }, + }) + + assert.strictEqual(users.length, 1) + assert.strictEqual(users[0].name, 'deleted') + }) + + it('keeps enforcing the default filter when the query omits the field', async () => { + const userService = await setupWith() + + const users = await userService.find({ query: { key: 'a' } }) + + assert.strictEqual(users.length, 1) + assert.strictEqual(users[0].name, 'active') + }) + + it('allowQueryOverride: false always enforces the default filter', async () => { + const userService = await setupWith({ allowQueryOverride: false }) + + const users = await userService.find({ + query: { deletedAt: { $ne: null } }, + }) + + assert.strictEqual(users.length, 0) + }) + }) }) diff --git a/src/hooks/soft-delete/soft-delete.hook.ts b/src/hooks/soft-delete/soft-delete.hook.ts index 8a1b841..070f633 100755 --- a/src/hooks/soft-delete/soft-delete.hook.ts +++ b/src/hooks/soft-delete/soft-delete.hook.ts @@ -1,5 +1,5 @@ import type { HookContext, NextFunction } from '@feathersjs/feathers' -import { addToQuery, checkContext } from '../../utils/index.js' +import { addToQuery, checkContext, queryDefaults } from '../../utils/index.js' import type { TransformParamsFn } from '../../types.js' import { transformParams } from '../../utils/transform-params/transform-params.util.js' import { early, type Promisable } from '../../internal.utils.js' @@ -36,6 +36,18 @@ export interface SoftDeleteOptions { * If you set this option to `true`, it will use the `.patch()` method with hooks instead. */ usePatchWithHooks?: boolean + + /** + * By default, if the incoming `params.query` already references a key of + * `deletedQuery` (e.g. `deletedAt`) — including nested inside `$and`/`$or`/`$nor` — + * the `deletedQuery` filter is NOT added, letting the caller read soft-deleted + * items while `remove` still soft-deletes them. + * + * Set this to `false` to always enforce the `deletedQuery` filter. + * + * @default true + */ + allowQueryOverride?: boolean } /** @@ -75,14 +87,16 @@ export const softDelete = ( return } - const { deletedQuery, removeData } = options + const { deletedQuery, removeData, allowQueryOverride = true } = options let deleteQuery = getValue(deletedQuery, context) if (isPromise(deleteQuery)) { deleteQuery = await deleteQuery } - const query = addToQuery(context.params.query, deleteQuery) + const query = allowQueryOverride + ? queryDefaults(context.params.query, deleteQuery) + : addToQuery(context.params.query, deleteQuery) const params = transformParams( { diff --git a/src/utils/index.ts b/src/utils/index.ts index 2c0749b..f32f022 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -20,4 +20,6 @@ export * from './transform-params/transform-params.util.js' export * from './sort-query-properties/sort-query-properties.util.js' export * from './wait-for-service-event/wait-for-service-event.util.js' export * from './walk-query/walk-query.util.js' +export * from './query-has-property/query-has-property.util.js' +export * from './query-defaults/query-defaults.util.js' export * from './zip-data-result/zip-data-result.util.js' diff --git a/src/utils/query-defaults/query-defaults.util.md b/src/utils/query-defaults/query-defaults.util.md new file mode 100644 index 0000000..5285efc --- /dev/null +++ b/src/utils/query-defaults/query-defaults.util.md @@ -0,0 +1,9 @@ +--- +title: queryDefaults +category: utils +see: + - utils/query-has-property + - utils/add-to-query + - hooks/set-query-defaults + - utils/walk-query +--- diff --git a/src/utils/query-defaults/query-defaults.util.test.ts b/src/utils/query-defaults/query-defaults.util.test.ts new file mode 100644 index 0000000..df4fa13 --- /dev/null +++ b/src/utils/query-defaults/query-defaults.util.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect } from 'vitest' +import { queryDefaults } from './query-defaults.util.js' + +describe('queryDefaults', () => { + it('adds a default when the field is absent', () => { + expect(queryDefaults({ status: 'x' }, { isTemplate: false })).toEqual({ + status: 'x', + isTemplate: false, + }) + }) + + it('creates the query when given undefined', () => { + expect(queryDefaults(undefined, { isTemplate: false })).toEqual({ + isTemplate: false, + }) + }) + + it('does not add when the field is present at top level', () => { + expect(queryDefaults({ isTemplate: true }, { isTemplate: false })).toEqual({ + isTemplate: true, + }) + }) + + it('does not add when the field is referenced nested in $or', () => { + const query = { $or: [{ isTemplate: true }, { foo: 1 }] } + expect(queryDefaults(query, { isTemplate: false })).toEqual(query) + }) + + it('adds each default independently (per-field)', () => { + expect( + queryDefaults( + { isTemplate: true }, + { isTemplate: false, archived: false }, + ), + ).toEqual({ isTemplate: true, archived: false }) + }) + + it('returns the same query reference when all defaults are present', () => { + const query = { isTemplate: true } + expect(queryDefaults(query, { isTemplate: false })).toBe(query) + }) + + it('does not mutate the input query', () => { + const query = { status: 'x' } + const snapshot = structuredClone(query) + queryDefaults(query, { isTemplate: false }) + expect(query).toEqual(snapshot) + }) +}) diff --git a/src/utils/query-defaults/query-defaults.util.ts b/src/utils/query-defaults/query-defaults.util.ts new file mode 100644 index 0000000..2099a73 --- /dev/null +++ b/src/utils/query-defaults/query-defaults.util.ts @@ -0,0 +1,39 @@ +import type { Query } from '@feathersjs/feathers' +import { addToQuery } from '../add-to-query/add-to-query.util.js' +import { queryHasProperty } from '../query-has-property/query-has-property.util.js' + +/** + * Adds default properties to a Feathers query — but only for fields the query does + * not already constrain. Presence is checked with {@link queryHasProperty}, so a field + * referenced anywhere (including nested in `$and`/`$or`/`$nor`) is left untouched and + * the caller keeps control over it. The query is treated as the `data` equivalent of + * the `defaults` transformer. Each default is applied independently (per-field). + * + * @example + * ```ts + * import { queryDefaults } from 'feathers-utils/utils' + * + * queryDefaults({ status: 'active' }, { isTemplate: false }) + * // => { status: 'active', isTemplate: false } + * + * queryDefaults({ $or: [{ isTemplate: true }] }, { isTemplate: false }) + * // => { $or: [{ isTemplate: true }] } (untouched — already referenced) + * ``` + * + * @see https://utils.feathersjs.com/utils/query-defaults.html + */ +export const queryDefaults = ( + query: Query | undefined, + defaults: Query, +): Query => { + const source: Query = query ?? {} + + const toAdd: Query = {} + for (const key in defaults) { + if (!queryHasProperty(source, key)) { + toAdd[key] = defaults[key] + } + } + + return addToQuery(source, toAdd) +} diff --git a/src/utils/query-has-property/query-has-property.util.md b/src/utils/query-has-property/query-has-property.util.md new file mode 100644 index 0000000..7234523 --- /dev/null +++ b/src/utils/query-has-property/query-has-property.util.md @@ -0,0 +1,6 @@ +--- +title: queryHasProperty +category: utils +see: + - utils/walk-query +--- diff --git a/src/utils/query-has-property/query-has-property.util.test.ts b/src/utils/query-has-property/query-has-property.util.test.ts new file mode 100644 index 0000000..8b1644c --- /dev/null +++ b/src/utils/query-has-property/query-has-property.util.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect } from 'vitest' +import { queryHasProperty } from './query-has-property.util.js' + +describe('queryHasProperty', () => { + it('finds a top-level property', () => { + expect(queryHasProperty({ isTemplate: true }, 'isTemplate')).toBe(true) + }) + + it('returns false when the property is absent', () => { + expect(queryHasProperty({ age: { $gt: 18 } }, 'isTemplate')).toBe(false) + }) + + it('finds a property that uses an operator', () => { + expect(queryHasProperty({ isTemplate: { $ne: null } }, 'isTemplate')).toBe( + true, + ) + }) + + it('finds a property nested in $and', () => { + expect( + queryHasProperty({ $and: [{ isTemplate: true }] }, 'isTemplate'), + ).toBe(true) + }) + + it('finds a property nested in $or', () => { + expect( + queryHasProperty({ $or: [{ isTemplate: true }] }, 'isTemplate'), + ).toBe(true) + }) + + it('finds a property nested in $nor', () => { + expect( + queryHasProperty({ $nor: [{ isTemplate: true }] }, 'isTemplate'), + ).toBe(true) + }) + + it('finds a deeply nested property ($or > $and)', () => { + const query = { $or: [{ $and: [{ isTemplate: true }] }] } + expect(queryHasProperty(query, 'isTemplate')).toBe(true) + }) + + it('accepts an array of names and returns true if any is present', () => { + expect(queryHasProperty({ status: 'x' }, ['isTemplate', 'status'])).toBe( + true, + ) + }) + + it('accepts an array of names and returns false if none is present', () => { + expect(queryHasProperty({ age: 1 }, ['isTemplate', 'status'])).toBe(false) + }) + + it('returns false for an empty query', () => { + expect(queryHasProperty({}, 'isTemplate')).toBe(false) + }) + + it('does not mutate the query', () => { + const query = { $and: [{ isTemplate: true }], age: { $gt: 1 } } + const snapshot = structuredClone(query) + queryHasProperty(query, 'isTemplate') + expect(query).toEqual(snapshot) + }) +}) diff --git a/src/utils/query-has-property/query-has-property.util.ts b/src/utils/query-has-property/query-has-property.util.ts new file mode 100644 index 0000000..091b115 --- /dev/null +++ b/src/utils/query-has-property/query-has-property.util.ts @@ -0,0 +1,37 @@ +import type { Query } from '@feathersjs/feathers' +import { toArray, type MaybeArray } from '../../internal.utils.js' +import { walkQuery } from '../walk-query/walk-query.util.js' + +/** + * Checks whether a Feathers query contains one or more properties — including + * properties nested inside `$and`/`$or`/`$nor` arrays. Returns `true` as soon as + * any of the given property names is found. The query is not mutated. + * + * @example + * ```ts + * import { queryHasProperty } from 'feathers-utils/utils' + * + * queryHasProperty({ isTemplate: true }, 'isTemplate') // true + * queryHasProperty({ $or: [{ isTemplate: true }] }, 'isTemplate') // true + * queryHasProperty({ age: { $gt: 18 } }, ['isTemplate', 'status']) // false + * ``` + * + * @see https://utils.feathersjs.com/utils/query-has-property.html + */ +export const queryHasProperty = ( + query: Query, + property: MaybeArray, +): boolean => { + const properties = new Set(toArray(property)) + + let found = false + walkQuery(query, ({ property: key, stop }) => { + if (properties.has(key)) { + found = true + stop() + } + // returning undefined leaves the value untouched → no mutation + }) + + return found +} diff --git a/src/utils/walk-query/walk-query.util.test.ts b/src/utils/walk-query/walk-query.util.test.ts index 7149572..8743c7a 100644 --- a/src/utils/walk-query/walk-query.util.test.ts +++ b/src/utils/walk-query/walk-query.util.test.ts @@ -138,4 +138,49 @@ describe('walkQuery', () => { }) expect(query).toBe(result) }) + + it('stops the traversal when stop() is called', () => { + const query = { a: 1, b: 2, c: 3 } + + const visited: string[] = [] + walkQuery(query, ({ property, stop }) => { + visited.push(property) + if (property === 'b') { + stop() + } + }) + + expect(visited).toEqual(['a', 'b']) + }) + + it('stops the traversal inside nested $and/$or', () => { + const query = { + $and: [{ a: 1 }, { $or: [{ b: 2 }, { c: 3 }] }], + d: 4, + } + + const visited: string[] = [] + walkQuery(query, ({ property, stop }) => { + visited.push(property) + if (property === 'b') { + stop() + } + }) + + expect(visited).toEqual(['a', 'b']) + }) + + it('applies the replacement of the stopping call, then halts', () => { + const query = { a: 1, b: 2 } + + const result = walkQuery(query, ({ property, value, stop }) => { + if (property === 'a') { + stop() + return value * 10 + } + return value + }) + + expect(result).toEqual({ a: 10, b: 2 }) + }) }) diff --git a/src/utils/walk-query/walk-query.util.ts b/src/utils/walk-query/walk-query.util.ts index 906a08b..899af1a 100644 --- a/src/utils/walk-query/walk-query.util.ts +++ b/src/utils/walk-query/walk-query.util.ts @@ -12,15 +12,27 @@ export type WalkQueryOptions = { operator: string | undefined value: any path: (string | number)[] + /** + * Stops the traversal. Any replacement value returned from the current walker + * call is still applied, but no further properties are visited. + */ + stop: () => void } export type WalkQueryCallback = (options: WalkQueryOptions) => any +type WalkQueryState = { stopped: boolean } + const _walkQueryUtil = ( query: Q, walker: WalkQueryCallback, + state: WalkQueryState, options?: WalkQueryOptionsInit | WalkQueryOptions, ): Q => { + const stop = () => { + state.stopped = true + } + let cloned = false const clonedSecond: Record = {} function set(key: string, value: any, secondKey?: string | number) { @@ -42,6 +54,10 @@ const _walkQueryUtil = ( } for (const key in query) { + if (state.stopped) { + break + } + if ( (key === '$or' || key === '$and' || key === '$nor') && Array.isArray(query[key]) @@ -51,8 +67,12 @@ const _walkQueryUtil = ( let copiedArray = false for (let i = 0, n = array.length; i < n; i++) { + if (state.stopped) { + break + } + const nestedQuery = array[i] - const transformed = _walkQueryUtil(nestedQuery, walker, { + const transformed = _walkQueryUtil(nestedQuery, walker, state, { ...options, path: [...(options?.path || []), key, i], }) @@ -77,6 +97,10 @@ const _walkQueryUtil = ( ) { let hasOperator = false for (const operator in query[key]) { + if (state.stopped) { + break + } + if (operator.startsWith('$')) { hasOperator = true const value = walker({ @@ -84,6 +108,7 @@ const _walkQueryUtil = ( path: [...(options?.path ?? []), key], property: key, value: query[key][operator], + stop, }) if (value !== undefined && value !== query[key][operator]) { @@ -98,6 +123,7 @@ const _walkQueryUtil = ( path: [...(options?.path ?? []), key], property: key, value: query[key], + stop, }) if (value !== undefined && value !== query[key]) { @@ -110,6 +136,7 @@ const _walkQueryUtil = ( path: [...(options?.path ?? []), key], property: key, value: query[key], + stop, }) if (value !== undefined && value !== query[key]) { @@ -124,7 +151,8 @@ const _walkQueryUtil = ( /** * Walks every property of a Feathers query (including nested `$and`/`$or`/`$nor` arrays) * and calls the `walker` function for each one. The walker receives the property name, operator, - * value, and path, and can return a replacement value. Returns a new query only if changes were made. + * value, path, and a `stop` function, and can return a replacement value. Calling `stop()` halts + * the traversal early. Returns a new query only if changes were made. * * @example * ```ts @@ -136,11 +164,23 @@ const _walkQueryUtil = ( * // => { age: { $gt: 18 } } * ``` * + * @example + * ```ts + * // stop early once a property is found + * let found = false + * walkQuery(query, ({ property, stop }) => { + * if (property === 'isTemplate') { + * found = true + * stop() + * } + * }) + * ``` + * * @see https://utils.feathersjs.com/utils/walk-query.html */ export const walkQuery = ( query: Q, walker: WalkQueryCallback, ): Q => { - return _walkQueryUtil(query, walker) + return _walkQueryUtil(query, walker, { stopped: false }) } diff --git a/test/index.test.ts b/test/index.test.ts index e0c2238..c500687 100755 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -16,6 +16,7 @@ const hooks = [ 'findOrCreate', 'iff', 'iffElse', + 'muteEvent', 'onDelete', 'paramsForServer', 'paramsFromClient', @@ -27,6 +28,7 @@ const hooks = [ 'resolveResult', 'setData', 'setField', + 'setQueryDefaults', 'setResult', 'setSlug', 'skippable', @@ -58,6 +60,8 @@ const utils = [ 'mutateData', 'mutateResult', 'patchBatch', + 'queryDefaults', + 'queryHasProperty', 'replaceData', 'replaceResult', 'skipResult',