Skip to content
Merged
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
1 change: 1 addition & 0 deletions src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,4 @@ export * from './skip-result/skip-result.util.js'
export * from './to-paginated/to-paginated.util.js'
export * from './transform-params/transform-params.util.js'
export * from './walk-query/walk-query.util.js'
export * from './zip-data-result/zip-data-result.util.js'
4 changes: 4 additions & 0 deletions src/utils/zip-data-result/zip-data-result.util.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
title: zipDataResult
category: utils
---
180 changes: 180 additions & 0 deletions src/utils/zip-data-result/zip-data-result.util.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import { expectTypeOf } from 'vitest'
import type { Application, HookContext } from '@feathersjs/feathers'
import { zipDataResult } from './zip-data-result.util.js'
import type { ZipDataResultItem } from './zip-data-result.util.js'
import type { MemoryService } from '@feathersjs/memory'

const make = (type: any, method: any, data: any, result: any) =>
({ type, method, data, result }) as HookContext

describe('zipDataResult (type tests)', () => {
type Todo = {
id: number
title: string
userId: number
}

type App = Application<{
todos: MemoryService<Todo>
}>

type TodoContext = HookContext<App, MemoryService<Todo>>

it('returns typed ZipDataResultItem array', () => {
const context = {
type: 'after',
method: 'create',
data: {},
result: {},
} as unknown as TodoContext
const result = zipDataResult(context)

expectTypeOf(result).toEqualTypeOf<
ZipDataResultItem<Partial<Todo>, Todo>[]
>()
})

it('works with a plain HookContext', () => {
const context = {
type: 'after',
method: 'create',
data: {},
result: {},
} as unknown as HookContext
const result = zipDataResult(context)

expectTypeOf(result).toEqualTypeOf<ZipDataResultItem<any, any>[]>()
})
})

describe('zipDataResult', () => {
it('throws for invalid context type', () => {
expect(() => make('before', 'create', [], [])).not.toThrow()
expect(() => zipDataResult(make('before', 'create', [], []))).toThrow()
})

it('throws for invalid context method', () => {
expect(() => zipDataResult(make('after', 'find', [], []))).toThrow()
expect(() => zipDataResult(make('after', 'get', {}, {}))).toThrow()
expect(() => zipDataResult(make('after', 'remove', {}, {}))).toThrow()
})

it('works with after create', () => {
expect(() =>
zipDataResult(make('after', 'create', {}, {})),
).not.toThrow()
})

it('works with after update', () => {
expect(() =>
zipDataResult(make('after', 'update', {}, {})),
).not.toThrow()
})

it('works with after patch', () => {
expect(() =>
zipDataResult(make('after', 'patch', {}, {})),
).not.toThrow()
})

it('works with around type', () => {
expect(() =>
zipDataResult(make('around', 'create', {}, {})),
).not.toThrow()
})

it('zips single data with single result', () => {
const data = { title: 'hello' }
const result = { id: 1, title: 'hello' }
const zipped = zipDataResult(make('after', 'create', data, result))

expect(zipped).toEqual([{ data, result }])
})

it('zips array data with array result', () => {
const data = [{ title: 'a' }, { title: 'b' }]
const result = [
{ id: 1, title: 'a' },
{ id: 2, title: 'b' },
]
const zipped = zipDataResult(make('after', 'create', data, result))

expect(zipped).toEqual([
{ data: { title: 'a' }, result: { id: 1, title: 'a' } },
{ data: { title: 'b' }, result: { id: 2, title: 'b' } },
])
})

it('repeats single data for each result item', () => {
const data = { title: 'hello' }
const result = [
{ id: 1, title: 'hello' },
{ id: 2, title: 'hello' },
]
const zipped = zipDataResult(make('after', 'patch', data, result))

expect(zipped).toEqual([
{ data, result: { id: 1, title: 'hello' } },
{ data, result: { id: 2, title: 'hello' } },
])
})

it('handles empty arrays', () => {
const zipped = zipDataResult(make('after', 'create', [], []))

expect(zipped).toEqual([])
})

it('calls onMismatch when array lengths differ', () => {
const onMismatch = vi.fn()
const data = [{ title: 'a' }]
const result = [
{ id: 1, title: 'a' },
{ id: 2, title: 'b' },
{ id: 3, title: 'c' },
]
const context = make('after', 'create', data, result)

const zipped = zipDataResult(context, { onMismatch })

expect(onMismatch).toHaveBeenCalledOnce()
expect(onMismatch).toHaveBeenCalledWith(context)
expect(zipped).toEqual([
{ data: { title: 'a' }, result: { id: 1, title: 'a' } },
{ data: undefined, result: { id: 2, title: 'b' } },
{ data: undefined, result: { id: 3, title: 'c' } },
])
})

it('does not call onMismatch when array lengths match', () => {
const onMismatch = vi.fn()
const data = [{ title: 'a' }]
const result = [{ id: 1, title: 'a' }]

zipDataResult(make('after', 'create', data, result), { onMismatch })

expect(onMismatch).not.toHaveBeenCalled()
})

it('does not call onMismatch when data is not an array', () => {
const onMismatch = vi.fn()
const data = { title: 'a' }
const result = [{ id: 1, title: 'a' }, { id: 2, title: 'a' }]

const zipped = zipDataResult(make('after', 'patch', data, result), { onMismatch })

expect(onMismatch).not.toHaveBeenCalled()
expect(zipped).toEqual([
{ data, result: { id: 1, title: 'a' } },
{ data, result: { id: 2, title: 'a' } },
]);
})

it('works without options', () => {
const data = { title: 'hello' }
const result = { id: 1, title: 'hello' }
const zipped = zipDataResult(make('after', 'create', data, result))

expect(zipped).toHaveLength(1)
})
})
67 changes: 67 additions & 0 deletions src/utils/zip-data-result/zip-data-result.util.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import type { HookContext } from '@feathersjs/feathers'
import { getDataIsArray } from '../get-data-is-array/get-data-is-array.util.js'
import { getResultIsArray } from '../get-result-is-array/get-result-is-array.util.js'
import { checkContext } from '../check-context/check-context.util.js'
import type {
DataSingleHookContext,
ResultSingleHookContext,
} from '../../utility-types/hook-context.js'

export type ZipDataResultOptions = {
onMismatch?: (context: HookContext) => void
}

export type ZipDataResultItem<D, R> = {
data: D | undefined
result: R | undefined
}

/**
* Pairs each item in `context.data` with its corresponding item in `context.result` by index.
* Handles both single-item and array data, normalizing them into an array of `{ data, result }` pairs.
* Only works in `after`/`around` hooks for `create`, `update`, and `patch` methods.
*
* @example
* ```ts
* import { zipDataResult } from 'feathers-utils/utils'
*
* const pairs = zipDataResult(context)
* pairs.forEach(({ data, result }) => { /* process each pair *\/ })
* ```
*
* @see https://utils.feathersjs.com/utils/zip-data-result.html
*/
export function zipDataResult<
H extends HookContext,
D extends DataSingleHookContext<H> = DataSingleHookContext<H>,
R extends ResultSingleHookContext<H> = ResultSingleHookContext<H>,
>(context: H, options?: ZipDataResultOptions): ZipDataResultItem<D, R>[] {
checkContext(context, ['after', 'around'], ['create', 'update', 'patch'])

const input = getDataIsArray(context)
const output = getResultIsArray(context)

if (
input.isArray &&
output.isArray &&
input.data.length !== output.result.length
) {
options?.onMismatch?.(context)
}

const result: ZipDataResultItem<D, R>[] = []

const length = Math.max(input.data.length, output.result.length)

for (let i = 0; i < length; i++) {
const dataItem = input.isArray ? input.data.at(i) : input.data[0]
const resultItem = output.result.at(i)

result.push({
data: dataItem,
result: resultItem,
})
}

return result
}
1 change: 1 addition & 0 deletions test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ const utils = [
'toPaginated',
'transformParams',
'walkQuery',
"zipDataResult",
] satisfies (keyof typeof exportedUtils)[]

const predicates = [
Expand Down
Loading