UNPKG

@sanity/diff

Version:

Generates diffs between documents and primitive types

394 lines (335 loc) • 9.92 kB
import {replaceProperty} from '../helpers' import {type ArrayDiff, type ArrayInput, type DiffOptions, type ItemDiff} from '../types' import {addedInput, diffInput, removedInput} from './diffInput' import {getLongestCommonSubsequence} from './lcs' export function diffArray<A>( fromInput: ArrayInput<A>, toInput: ArrayInput<A>, options: DiffOptions, ): ArrayDiff<A> { if (fromInput === toInput) { const fromValue = fromInput.value const toValue = toInput.value return { type: 'array', action: 'unchanged', isChanged: false, fromValue, toValue, get items(): ItemDiff<A>[] { const items = diffExactByPosition(fromInput, toInput, options) if (!items) throw new Error('invariant broken: equivalent input, but diff detected') return replaceProperty(this, 'items', items) }, } } // The key-ed approach should handle most cases (_key'ed objects, primitives): const keyedA = indexByKey(fromInput) const keyedB = indexByKey(toInput) if (keyedA && keyedB) { return diffArrayByKey(fromInput, keyedA, toInput, keyedB, options) } // Check if they are 100% equivalent: const items = diffExactByPosition(fromInput, toInput, options) if (items) return buildArrayDiff(fromInput, toInput, items, false) // Otherwise we create a diff where we model it as removing the from-items and adding the to-items. return diffArrayByReinsert(fromInput, toInput, options) } function buildArrayDiff<A>( fromInput: ArrayInput<A>, toInput: ArrayInput<A>, items: ItemDiff<A>[], isChanged: boolean, ): ArrayDiff<A> { const fromValue = fromInput.value const toValue = toInput.value return isChanged ? { type: 'array', action: 'changed', isChanged: true, fromValue, toValue, items, annotation: toInput.annotation, } : { type: 'array', action: 'unchanged', isChanged: false, fromValue, toValue, items, } } /** * Diffes the two arrays by position. Returns an `items` array if they are unchanged, or undefined * if there are any changes anywhere. */ function diffExactByPosition<A>( fromInput: ArrayInput<A>, toInput: ArrayInput<A>, options: DiffOptions, ): ItemDiff<A>[] | undefined { if (fromInput.length !== toInput.length) { return undefined } const items: ItemDiff<A>[] = [] for (let idx = 0; idx < fromInput.length; idx++) { const diff = diffInput(fromInput.at(idx), toInput.at(idx), options) if (diff.isChanged) { return undefined } items.push({ fromIndex: idx, toIndex: idx, hasMoved: false, diff, annotation: toInput.annotationAt(idx), }) } return items } function diffArrayByReinsert<A>( fromInput: ArrayInput<A>, toInput: ArrayInput<A>, options: DiffOptions, ): ArrayDiff<A> { const items: ItemDiff<A>[] = [] for (let idx = 0; idx < toInput.length; idx++) { const input = toInput.at(idx) items.push({ fromIndex: undefined, toIndex: idx, hasMoved: false, diff: addedInput(input, undefined, options), annotation: input.annotation, }) } for (let idx = 0; idx < fromInput.length; idx++) { const input = fromInput.at(idx) items.push({ fromIndex: idx, toIndex: undefined, hasMoved: false, diff: removedInput(input, undefined, options), annotation: input.annotation, }) } return buildArrayDiff(fromInput, toInput, items, true) } type Key = string | number | boolean /** * Diff an array when all the elements have _key in the same position. */ function diffArrayByKey<A>( fromArray: ArrayInput<A>, fromKeyIndex: KeyIndex, toArray: ArrayInput<A>, toKeyIndex: KeyIndex, options: DiffOptions, ): ArrayDiff<A> { const items: ItemDiff<A>[] = [] let isChanged = false function diffCommon(key: Key, fromIndex: number, toIndex: number, hasMoved: boolean) { deletePositionInIndex(fromKeyIndex.index, key, fromIndex) deletePositionInIndex(toKeyIndex.index, key, toIndex) const fromInput = fromArray.at(fromIndex) const toInput = toArray.at(toIndex) const diff = diffInput(fromInput, toInput) items.push({ fromIndex, toIndex, hasMoved, diff, annotation: toArray.annotationAt(toIndex), }) if (diff.isChanged || fromIndex !== toIndex) { isChanged = true } } const lcs = getLongestCommonSubsequence(fromKeyIndex.keys, toKeyIndex.keys) for (let fromIndex = 0; fromIndex < fromKeyIndex.keys.length; fromIndex++) { const key = fromKeyIndex.keys[fromIndex] const subsequenceIdx = lcs.prevIndices.indexOf(fromIndex) if (subsequenceIdx !== -1) { // Part of the common subsequence => hasMoved:false diffCommon(key, fromIndex, lcs.nextIndices[subsequenceIdx], false) continue } // Not a part of the subsequence. Try to find another item which has the same key // and also is not part of the common subsequence. const toIndexes = toKeyIndex.index.get(key) const toIndex = toIndexes && toIndexes.find((idx) => !lcs.nextIndices.includes(idx)) if (toIndex !== undefined) { diffCommon(key, fromIndex, toIndex, true) continue } const input = fromArray.at(fromIndex) items.push({ fromIndex, toIndex: undefined, hasMoved: false, diff: removedInput(input, undefined, options), annotation: fromArray.annotationAt(fromIndex), }) isChanged = true } // The remaining data in toKeyIndex are the new elements which has been added for (const positions of toKeyIndex.index.values()) { for (const toIndex of positions) { const input = toArray.at(toIndex) items.push({ fromIndex: undefined, toIndex, hasMoved: false, diff: addedInput(input, undefined, options), annotation: toArray.annotationAt(toIndex), }) } isChanged = true } items.sort(compareItemDiff) return buildArrayDiff(fromArray, toArray, items, isChanged) } function compareItemDiff<A>(a: ItemDiff<A>, b: ItemDiff<A>): number { if (a.toIndex !== undefined && b.toIndex !== undefined) { return a.toIndex - b.toIndex } if (a.fromIndex !== undefined && b.fromIndex !== undefined) { return a.fromIndex - b.fromIndex } if (a.fromIndex !== undefined && b.toIndex !== undefined) { // A was removed and B was added. Prefer to sort removals last. return -1 } if (a.toIndex !== undefined && b.fromIndex !== undefined) { // A was added and B was removed. Prefer to sort removals last. return 1 } throw new Error('invalid item diff comparison') } function deletePositionInIndex(index: Map<Key, number[]>, key: Key, pos: number) { const positions = index.get(key)! deleteArrayValue(positions, pos) if (positions.length === 0) { index.delete(key) } } function deleteArrayValue<E>(arr: E[], value: E) { const idx = arr.indexOf(value) if (idx === -1) throw new Error('value not found') arr.splice(idx, 1) } type KeyIndex = { keys: Key[] index: Map<Key, number[]> } /** * Indexes the array by a key. This handles cases where the items are: * * - Objects with _key * - Strings * - Numbers */ function indexByKey<A>(arr: ArrayInput<A>): KeyIndex | undefined { const index = new Map<Key, number[]>() const keys: Key[] = [] const length = arr.length for (let i = 0; i < length; i++) { const item = arr.at(i) let key: Key | null = null switch (item.type) { case 'string': key = `s${item.value}` break case 'number': key = item.value break case 'boolean': key = item.value break case 'null': key = 'n' break case 'object': { const keyField = item.get('_key') if (keyField && keyField.type === 'string') { key = `k${keyField.value}` // We do not handle duplicate _key if (index.has(key)) return undefined } } break default: } // No key => abort if (key === null) return undefined keys.push(key) let positions = index.get(key) if (!positions) { positions = [] index.set(key, positions) } positions.push(i) } // All is good. return {keys, index} } export function removedArray<A>( input: ArrayInput<A>, toValue: null | undefined, options: DiffOptions, ): ArrayDiff<A> & {action: 'removed'} { return { type: 'array', action: 'removed', isChanged: true, fromValue: input.value, toValue, annotation: input.annotation, get items(): ArrayDiff<A>['items'] { const items: ArrayDiff<A>['items'] = [] for (let i = 0; i < input.length; i++) { const item = input.at(i) items.push({ fromIndex: i, toIndex: undefined, hasMoved: false, diff: removedInput(item, undefined, options), annotation: input.annotationAt(i), }) } return replaceProperty(this, 'items', items) }, } } export function addedArray<A>( input: ArrayInput<A>, fromValue: null | undefined, options: DiffOptions, ): ArrayDiff<A> & {action: 'added'} { return { type: 'array', action: 'added', isChanged: true, fromValue, toValue: input.value, annotation: input.annotation, get items(): ArrayDiff<A>['items'] { const items: ArrayDiff<A>['items'] = [] for (let i = 0; i < input.length; i++) { const item = input.at(i) items.push({ fromIndex: undefined, toIndex: i, hasMoved: false, diff: addedInput(item, undefined, options), annotation: input.annotationAt(i), }) } return replaceProperty(this, 'items', items) }, } }