From 17e7a3c1a37558a3aceb7bcb1630f0dde51749a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rge=20N=C3=A6ss?= Date: Mon, 2 Sep 2024 15:26:22 +0200 Subject: [PATCH] feat: add support for array remove operation (#19) * feat: add support for array remove operation * fix: improve typings for remove operation --- examples/ts/apply-patch-mutation.ts | 3 ++ .../react/components/FormatPatchMutation.tsx | 7 +++ src/apply/patch/__test__/array.test.ts | 36 +++++++++++++- src/apply/patch/operations/array.ts | 15 ++++++ src/apply/patch/typings/applyOp.ts | 48 ++++++++++++++++++- src/encoders/compact/encode.ts | 3 ++ src/encoders/compact/types.ts | 9 ++++ src/encoders/sanity/encode.ts | 5 ++ src/formatters/compact.ts | 3 ++ src/mutations/operations/creators.ts | 13 +++++ src/mutations/operations/types.ts | 7 +++ 11 files changed, 147 insertions(+), 2 deletions(-) diff --git a/examples/ts/apply-patch-mutation.ts b/examples/ts/apply-patch-mutation.ts index 55bcc12..030c87d 100644 --- a/examples/ts/apply-patch-mutation.ts +++ b/examples/ts/apply-patch-mutation.ts @@ -7,6 +7,7 @@ import { insertBefore, patch, prepend, + remove, setIfMissing, unassign, unset, @@ -17,6 +18,7 @@ const document = { _id: 'test', _type: 'foo', unsetme: 'yes', + someArray: [{_key: 'foo'}, {_key: 'bar'}], unassignme: 'please', assigned: {existing: 'prop'}, } as const @@ -30,6 +32,7 @@ const patches = patch('test', [ at('cities', insertAfter(['Chicago'], 1)), at('cities', insertBefore(['Raleigh'], 3)), at('unsetme', unset()), + at('someArray', remove({_key: 'foo'})), at([], unassign(['unassignme'])), at('hmmm', assign({other: 'value'})), ]) diff --git a/examples/web/lib/mutate-formatter/react/components/FormatPatchMutation.tsx b/examples/web/lib/mutate-formatter/react/components/FormatPatchMutation.tsx index 627da3c..6c045f9 100644 --- a/examples/web/lib/mutate-formatter/react/components/FormatPatchMutation.tsx +++ b/examples/web/lib/mutate-formatter/react/components/FormatPatchMutation.tsx @@ -125,6 +125,13 @@ function FormatOp(props: {op: Operation}) { ) } + if (op.type === 'remove') { + return ( + + {op.type} ({formatReferenceItem(op.referenceItem)})) + + ) + } // @ts-expect-error all cases are covered throw new Error(`Invalid operation type: ${op.type}`) } diff --git a/src/apply/patch/__test__/array.test.ts b/src/apply/patch/__test__/array.test.ts index fcbc78e..b9ff8c6 100644 --- a/src/apply/patch/__test__/array.test.ts +++ b/src/apply/patch/__test__/array.test.ts @@ -1,6 +1,6 @@ import {expect, test} from 'vitest' -import {insert, replace} from '../../../mutations/operations/creators' +import {insert, remove, replace} from '../../../mutations/operations/creators' import {applyOp} from '../applyOp' test('replace on item', () => { @@ -151,3 +151,37 @@ test('insert relative to nonexisting keyed path elements', () => { applyOp(insert(['INSERT!'], 'after', {_key: 'foo'}), []), ).toThrow() }) + +test('remove item at key', () => { + expect( + applyOp(remove({_key: 'foo'}), [ + {_key: 'one'}, + {_key: 'foo'}, + {_key: 'two'}, + ]), + ).toEqual([{_key: 'one'}, {_key: 'two'}]) + + expect( + applyOp(remove({_key: 'foo'}), [{_key: 'foo'}, {_key: 'two'}]), + ).toEqual([{_key: 'two'}]) + expect( + applyOp(remove({_key: 'foo'}), [{_key: 'one'}, {_key: 'foo'}]), + ).toEqual([{_key: 'one'}]) + + expect(applyOp(remove({_key: 'foo'}), [{_key: 'foo'}])).toEqual([]) +}) + +test('remove item at index', () => { + expect( + applyOp(remove(1), [{_key: 'one'}, {_key: 'foo'}, {_key: 'two'}]), + ).toEqual([{_key: 'one'}, {_key: 'two'}]) + + expect(applyOp(remove(0), [{_key: 'foo'}, {_key: 'two'}])).toEqual([ + {_key: 'two'}, + ]) + expect(applyOp(remove(1), [{_key: 'one'}, {_key: 'foo'}])).toEqual([ + {_key: 'one'}, + ]) + + expect(applyOp(remove(0), [{_key: 'foo'}])).toEqual([]) +}) diff --git a/src/apply/patch/operations/array.ts b/src/apply/patch/operations/array.ts index 4234bdb..104f7a4 100644 --- a/src/apply/patch/operations/array.ts +++ b/src/apply/patch/operations/array.ts @@ -2,6 +2,7 @@ import { type InsertOp, type KeyedPathElement, type RelativePosition, + type RemoveOp, type ReplaceOp, type TruncateOp, type UpsertOp, @@ -87,6 +88,20 @@ export function replace< } return splice(currentValue, index, op.items.length, op.items) } +export function remove< + O extends RemoveOp, + CurrentValue extends unknown[], +>(op: O, currentValue: CurrentValue) { + if (!Array.isArray(currentValue)) { + throw new TypeError('Cannot apply "remove()" on non-array value') + } + + const index = findTargetIndex(currentValue, op.referenceItem) + if (index === null) { + throw new Error(`Found no matching array element to replace`) + } + return splice(currentValue, index, 1, []) +} export function truncate( op: O, diff --git a/src/apply/patch/typings/applyOp.ts b/src/apply/patch/typings/applyOp.ts index 35642b2..7d23369 100644 --- a/src/apply/patch/typings/applyOp.ts +++ b/src/apply/patch/typings/applyOp.ts @@ -8,6 +8,7 @@ import { type InsertOp, type KeyedPathElement, type Operation, + type RemoveOp, type ReplaceOp, type SetIfMissingOp, type SetOp, @@ -88,6 +89,36 @@ export type InsertAtIndex< NormalizeIndex> > +export type DropFirst = Array extends [ + infer Head, + ...infer Rest, +] + ? Rest + : [] + +export type _RemoveAtIndex = + Between> extends true + ? Call, Current> extends [infer Head, infer Tail] + ? Head extends AnyArray + ? Tail extends AnyArray + ? [ + ...(Head extends never[] ? [] : Head), + ...(Tail extends never[] + ? [] + : Tail extends unknown[] + ? DropFirst + : Tail), + ] + : never + : never + : never + : Current + +export type RemoveAtIndex< + Current extends unknown[], + Index extends number, +> = _RemoveAtIndex>> + export type ArrayInsert< Current extends unknown[], Items extends unknown[], @@ -101,6 +132,16 @@ export type ArrayInsert< : (E | ArrayElement)[] : Current +export type ArrayRemove< + Current extends unknown[], + Ref extends number | KeyedPathElement, +> = number extends Ref + ? Current + : Ref extends number + ? RemoveAtIndex + : // todo: look up index of item with _key + Current + export type Assign = { [K in keyof Attrs | keyof Current]: K extends keyof Attrs ? Attrs[K] @@ -155,4 +196,9 @@ export type ApplyOp = Current extends never } : O extends DiffMatchPatchOp ? string - : never + : O extends RemoveOp + ? Current extends AnyArray + ? ArrayRemove, Ref> + : Current + : // fallback + Current diff --git a/src/encoders/compact/encode.ts b/src/encoders/compact/encode.ts index 6ab5b13..a3dd871 100644 --- a/src/encoders/compact/encode.ts +++ b/src/encoders/compact/encode.ts @@ -105,6 +105,9 @@ function encodePatchMutation( if (op.type === 'truncate') { return ['patch', 'truncate', id, path, [op.startIndex, op.endIndex]] } + if (op.type === 'remove') { + return ['patch', 'remove', id, path, [encodeItemRef(op.referenceItem)]] + } // @ts-expect-error all cases are covered throw new Error(`Invalid operation type: ${op.type}`) } diff --git a/src/encoders/compact/types.ts b/src/encoders/compact/types.ts index f8d9730..5d8aac0 100644 --- a/src/encoders/compact/types.ts +++ b/src/encoders/compact/types.ts @@ -86,6 +86,14 @@ export type ReplaceMutation = [ [ItemRef, AnyArray], RevisionLock?, ] +export type RemoveMutation = [ + 'patch', + 'remove', + Id, + CompactPath, + [ItemRef], + RevisionLock?, +] export type SetMutation = ['patch', 'set', Id, CompactPath, any, RevisionLock?] export type SetIfMissingMutation = [ 'patch', @@ -118,6 +126,7 @@ export type CompactPatchMutation = | AssignMutation | UnassignMutation | ReplaceMutation + | RemoveMutation export type CompactMutation = | DeleteMutation diff --git a/src/encoders/sanity/encode.ts b/src/encoders/sanity/encode.ts index 12a51f9..6baadb1 100644 --- a/src/encoders/sanity/encode.ts +++ b/src/encoders/sanity/encode.ts @@ -113,6 +113,11 @@ function patchToSanity(patch: NodePatch) { }, } } + if (op.type === 'remove') { + return { + unset: [stringifyPath(path.concat(op.referenceItem))], + } + } //@ts-expect-error all cases should be covered throw new Error(`Unknown operation type ${op.type}`) } diff --git a/src/formatters/compact.ts b/src/formatters/compact.ts index 7c69d69..7041449 100644 --- a/src/formatters/compact.ts +++ b/src/formatters/compact.ts @@ -90,6 +90,9 @@ function formatPatchMutation(patch: NodePatch): string { if (op.type === 'truncate') { return [path, `truncate(${op.startIndex}, ${op.endIndex}`].join(': ') } + if (op.type === 'remove') { + return [path, `remove(${encodeItemRef(op.referenceItem)})`].join(': ') + } // @ts-expect-error all cases are covered throw new Error(`Invalid operation type: ${op.type}`) } diff --git a/src/mutations/operations/creators.ts b/src/mutations/operations/creators.ts index 0199274..6e60400 100644 --- a/src/mutations/operations/creators.ts +++ b/src/mutations/operations/creators.ts @@ -13,6 +13,7 @@ import { type InsertOp, type KeyedPathElement, type RelativePosition, + type RemoveOp, type ReplaceOp, type SetIfMissingOp, type SetOp, @@ -135,6 +136,18 @@ export function replace< } } +/* + Remove an item from an array by either key or index + */ +export function remove( + referenceItem: ReferenceItem, +): RemoveOp { + return { + type: 'remove', + referenceItem, + } +} + /* use this when the reference Items may or may not exist */ diff --git a/src/mutations/operations/types.ts b/src/mutations/operations/types.ts index 5287501..91493c8 100644 --- a/src/mutations/operations/types.ts +++ b/src/mutations/operations/types.ts @@ -45,6 +45,12 @@ export type TruncateOp = { startIndex: number endIndex?: number } + +export type RemoveOp = { + type: 'remove' + referenceItem: ReferenceItem +} + export type ReplaceOp< Items extends AnyArray, ReferenceItem extends Index | KeyedPathElement, @@ -90,5 +96,6 @@ export type ArrayOp = | UpsertOp | ReplaceOp | TruncateOp + | RemoveOp export type PrimitiveOp = AnyOp | StringOp | NumberOp