Skip to content

Commit d8a7968

Browse files
authored
fix(core): correct keyed segment path representation and insert operation behavior (#552)
- Fix jsonMatch to return keyed segments in paths instead of numeric indices - Rewrite insert function to match content-lake behavior for before/after/replace operations - Simplify range matching implementation with flatMap - Remove unused code and fix typo in processMutations - Update test to expect correct keyed segment path format
1 parent 35d29b6 commit d8a7968

File tree

3 files changed

+120
-105
lines changed

3 files changed

+120
-105
lines changed

packages/core/src/document/patchOperations.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -301,7 +301,7 @@ describe('jsonMatch', () => {
301301
it('matches an element in an array by keyed segment', () => {
302302
const input = [{_key: 'bar'}, {_key: 'foo'}, {_key: 'baz'}]
303303
const result = jsonMatch(input, '[_key=="foo"]')
304-
expect(result).toEqual([{value: {_key: 'foo'}, path: [1]}])
304+
expect(result).toEqual([{value: {_key: 'foo'}, path: [{_key: 'foo'}]}])
305305
})
306306

307307
it('returns no match for a keyed segment when the input is not an array', () => {

packages/core/src/document/patchOperations.ts

Lines changed: 117 additions & 102 deletions
Original file line numberDiff line numberDiff line change
@@ -325,17 +325,9 @@ function matchRecursive(value: unknown, path: Path, currentPath: SingleValuePath
325325
const endIndex = end === '' ? value.length : end
326326

327327
// We'll accumulate all matches from each index in the range
328-
let results: MatchEntry[] = []
329-
330-
// Decide whether the range is exclusive or inclusive. The example in
331-
// the doc says "array[1:9]" => element 1 through 9 (non-inclusive?).
332-
// Typically, in slice terms, that is `array.slice(1, 9)` → includes
333-
// indices 1..8. If that's your intention, do i < endIndex.
334-
for (let i = startIndex; i < endIndex; i++) {
335-
results = results.concat(matchRecursive(value[i], rest, [...currentPath, i]))
336-
}
337-
338-
return results
328+
return value
329+
.slice(startIndex, endIndex)
330+
.flatMap((item, i) => matchRecursive(item, rest, [...currentPath, i + startIndex]))
339331
}
340332

341333
// 4) Keyed segment => find index in array
@@ -347,7 +339,7 @@ function matchRecursive(value: unknown, path: Path, currentPath: SingleValuePath
347339
}
348340

349341
const nextVal = value[arrIndex]
350-
return matchRecursive(nextVal, rest, [...currentPath, arrIndex])
342+
return matchRecursive(nextVal, rest, [...currentPath, {_key: keyed._key}])
351343
}
352344

353345
// this is a similar array key to the studio:
@@ -497,9 +489,6 @@ export function unset(input: unknown, pathExpressions: string[]): unknown {
497489
return ensureArrayKeysDeep(result)
498490
}
499491

500-
const operations = ['before', 'after', 'replace'] as const
501-
type Operation = (typeof operations)[number]
502-
503492
/**
504493
* Given an input object, a path expression (inside the insert patch object), and an array of items,
505494
* this function will insert or replace the matched items.
@@ -579,105 +568,135 @@ type Operation = (typeof operations)[number]
579568
* ```
580569
*/
581570
export function insert<R>(input: unknown, insertPatch: InsertPatch): R
582-
export function insert(input: unknown, insertPatch: InsertPatch): unknown {
583-
const operation = operations.find((op) => op in insertPatch)
571+
export function insert(input: unknown, {items, ...insertPatch}: InsertPatch): unknown {
572+
let operation
573+
let pathExpression
574+
575+
// behavior observed from content-lake when inserting:
576+
// 1. if the operation is before, out of all the matches, it will use the
577+
// insert the items before the first match that appears in the array
578+
// 2. if the operation is after, it will insert the items after the first
579+
// match that appears in the array
580+
// 3. if the operation is replace, then insert the items before the first
581+
// match and then delete the rest
582+
if ('before' in insertPatch) {
583+
operation = 'before' as const
584+
pathExpression = insertPatch.before
585+
} else if ('after' in insertPatch) {
586+
operation = 'after' as const
587+
pathExpression = insertPatch.after
588+
} else if ('replace' in insertPatch) {
589+
operation = 'replace' as const
590+
pathExpression = insertPatch.replace
591+
}
584592
if (!operation) return input
585-
586-
const {items} = insertPatch
587-
const pathExpression = (insertPatch as {[K in Operation]?: string} & {items: unknown})[operation]
588593
if (typeof pathExpression !== 'string') return input
589594

590-
// Helper to normalize a matched index given the parent array's length.
591-
function normalizeIndex(index: number, parentLength: number): number {
592-
switch (operation) {
593-
case 'before':
594-
// A negative index means "append" (i.e. insert before a hypothetical element
595-
// beyond the end of the array).
596-
return index < 0 ? parentLength : index
597-
case 'after':
598-
// For "after", if the matched index is negative, we treat it as "prepend":
599-
// by convention, we convert it to -1 so that later adding 1 produces 0.
600-
return index < 0 ? -1 : index
601-
default: // default to 'replace'
602-
// For replace, convert a negative index to the corresponding positive one.
603-
return index < 0 ? parentLength + index : index
604-
}
605-
}
595+
const parsedPath = parsePath(pathExpression)
596+
// in order to do an insert patch, you need to provide at least one path segment
597+
if (!parsedPath.length) return input
606598

607-
// Group the matched array entries by their parent array.
608-
interface GroupEntry {
609-
array: unknown[]
610-
pathToArray: SingleValuePath
611-
indexes: number[]
612-
}
613-
const grouped = new Map<unknown, GroupEntry>()
614-
jsonMatch(input, pathExpression)
615-
.map(({path}) => {
616-
const segment = path[path.length - 1]
617-
let index: number | undefined
618-
if (isKeySegment(segment)) {
619-
index = getIndexForKey(input, segment._key)
620-
} else if (typeof segment === 'number') {
621-
index = segment
622-
}
623-
if (typeof index !== 'number') return null
599+
const arrayPath = stringifyPath(parsedPath.slice(0, -1))
600+
const positionPath = stringifyPath(parsedPath.slice(-1))
624601

625-
const parentPath = path.slice(0, path.length - 1)
626-
const parent = getDeep(input, parentPath)
627-
if (!Array.isArray(parent)) return null
602+
const arrayMatches = jsonMatch<unknown[]>(input, arrayPath)
628603

629-
const normalizedIndex = normalizeIndex(index, parent.length)
630-
return {parent, parentPath, normalizedIndex}
631-
})
632-
.filter(isNonNullable)
633-
.forEach(({parent, parentPath, normalizedIndex}) => {
634-
if (grouped.has(parent)) {
635-
grouped.get(parent)!.indexes.push(normalizedIndex)
636-
} else {
637-
grouped.set(parent, {array: parent, pathToArray: parentPath, indexes: [normalizedIndex]})
638-
}
639-
})
604+
let result = input
640605

641-
// Sort the indexes for each grouped entry.
642-
const groupEntries = Array.from(grouped.values()).map((entry) => ({
643-
...entry,
644-
indexes: entry.indexes.sort((a, b) => a - b),
645-
}))
606+
for (const {path, value} of arrayMatches) {
607+
if (!Array.isArray(value)) continue
608+
let arr = value
646609

647-
// For each group, update the parent array using setDeep.
648-
const result = groupEntries.reduce<unknown>((acc, {array, indexes, pathToArray}) => {
649610
switch (operation) {
611+
case 'replace': {
612+
const indexesToRemove = new Set<number>()
613+
let position = Infinity
614+
615+
for (const itemMatch of jsonMatch(arr, positionPath)) {
616+
// there should only be one path segment for an insert patch, invalid otherwise
617+
if (itemMatch.path.length !== 1) continue
618+
const [segment] = itemMatch.path
619+
if (typeof segment === 'string') continue
620+
621+
let index
622+
623+
if (typeof segment === 'number') index = segment
624+
if (isKeySegment(segment)) index = getIndexForKey(arr, segment._key)
625+
if (typeof index !== 'number') continue
626+
if (index < 0) index = arr.length + index
627+
628+
indexesToRemove.add(index)
629+
if (index < position) position = index
630+
}
631+
632+
if (position === Infinity) continue
633+
634+
// remove all other indexes
635+
arr = arr
636+
.map((item, index) => ({item, index}))
637+
.filter(({index}) => !indexesToRemove.has(index))
638+
.map(({item}) => item)
639+
640+
// insert at the min index
641+
arr = [...arr.slice(0, position), ...items, ...arr.slice(position, arr.length)]
642+
643+
break
644+
}
650645
case 'before': {
651-
// Insert items before the first matched index.
652-
const firstIndex = indexes[0]
653-
return setDeep(acc, pathToArray, [
654-
...array.slice(0, firstIndex),
655-
...items,
656-
...array.slice(firstIndex),
657-
])
646+
let position = Infinity
647+
648+
for (const itemMatch of jsonMatch(arr, positionPath)) {
649+
if (itemMatch.path.length !== 1) continue
650+
const [segment] = itemMatch.path
651+
652+
if (typeof segment === 'string') continue
653+
654+
let index
655+
656+
if (typeof segment === 'number') index = segment
657+
if (isKeySegment(segment)) index = getIndexForKey(arr, segment._key)
658+
if (typeof index !== 'number') continue
659+
if (index < 0) index = arr.length - index
660+
if (index < position) position = index
661+
}
662+
663+
if (position === Infinity) continue
664+
665+
arr = [...arr.slice(0, position), ...items, ...arr.slice(position, arr.length)]
666+
667+
break
658668
}
659669
case 'after': {
660-
// Insert items after the last matched index.
661-
const lastIndex = indexes[indexes.length - 1] + 1
662-
return setDeep(acc, pathToArray, [
663-
...array.slice(0, lastIndex),
664-
...items,
665-
...array.slice(lastIndex),
666-
])
670+
let position = -Infinity
671+
672+
for (const itemMatch of jsonMatch(arr, positionPath)) {
673+
if (itemMatch.path.length !== 1) continue
674+
const [segment] = itemMatch.path
675+
676+
if (typeof segment === 'string') continue
677+
678+
let index
679+
680+
if (typeof segment === 'number') index = segment
681+
if (isKeySegment(segment)) index = getIndexForKey(arr, segment._key)
682+
if (typeof index !== 'number') continue
683+
if (index > position) position = index
684+
}
685+
686+
if (position === -Infinity) continue
687+
688+
arr = [...arr.slice(0, position + 1), ...items, ...arr.slice(position + 1, arr.length)]
689+
690+
break
667691
}
668-
// default to 'replace' behavior
692+
669693
default: {
670-
// Remove all matched items then insert the new items at the first match.
671-
const firstIndex = indexes[0]
672-
const indexSet = new Set(indexes)
673-
return setDeep(acc, pathToArray, [
674-
...array.slice(0, firstIndex),
675-
...items,
676-
...array.slice(firstIndex).filter((_, idx) => !indexSet.has(idx + firstIndex)),
677-
])
694+
continue
678695
}
679696
}
680-
}, input)
697+
698+
result = setDeep(result, path, arr)
699+
}
681700

682701
return ensureArrayKeysDeep(result)
683702
}
@@ -807,10 +826,6 @@ export function ifRevisionID(input: unknown, revisionId: string): unknown {
807826
return input
808827
}
809828

810-
function isNonNullable<T>(t: T): t is NonNullable<T> {
811-
return t !== null && t !== undefined
812-
}
813-
814829
const indexCache = new WeakMap<KeyedSegment[], Record<string, number | undefined>>()
815830
export function getIndexForKey(input: unknown, key: string): number | undefined {
816831
if (!Array.isArray(input)) return undefined

packages/core/src/document/processMutations.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ export type DocumentSet<TDocument extends SanityDocument = SanityDocument> = {
2626
[TDocumentId in string]?: TDocument | null
2727
}
2828

29-
type SupportPatchOperation = Exclude<keyof PatchOperations, 'merge'>
29+
type SupportedPatchOperation = Exclude<keyof PatchOperations, 'merge'>
3030

3131
// > If multiple patches are included, then the order of execution is as follows:
3232
// > - set, setIfMissing, unset, inc, dec, insert.
@@ -41,7 +41,7 @@ const patchOperations = {
4141
insert,
4242
diffMatchPatch,
4343
} satisfies {
44-
[K in SupportPatchOperation]: (
44+
[K in SupportedPatchOperation]: (
4545
input: unknown,
4646
pathExpressions: NonNullable<PatchOperations[K]>,
4747
) => unknown

0 commit comments

Comments
 (0)