javascripttypescriptrecursionhelperava

How to implement recursion for data comparison function?


I have this helper function in my app that tells me the changes of newData when compared to oldData.

How can I refactor my getChanges function to make the test below pass? I thought I may need to make this function recursive since it executes itself from within itself, but I am not totally sure how to implement that.

It looks like this:

getChanges helper function:

export function getChanges(oldData: Record<string, any>, newData: Record<string, any>): any {
  
  return Object.entries(newData).reduce((changes, [key, newVal]) => {

    if (JSON.stringify(oldData[key]) === JSON.stringify(newVal)) return changes
    changes[key] = newVal
    return changes

  }, {} as any)
}

During my actual tests I use ava's deepEqual to help make the comparison. For whatever reason though, one of the tests that I am running does not pass.

index.ts test 1 passes

import test from 'ava'
import { getChanges } from '../src/comparisonHelpers.js'

test('getChanges - flat', (t) => {
  const a = getChanges({}, {})
  const b = {}
  t.deepEqual(a, b)

  t.deepEqual(getChanges({ a: 1 }, { a: 1 }), {})
  t.deepEqual(getChanges({ a: 1 }, {}), {})
  t.deepEqual(getChanges({}, { a: 1 }), { a: 1 })

  const oldData = { a: 1, b: 1, c: 1 }
  const newData = { x: 1, a: 1, b: 2 }
  const result = getChanges(oldData, newData)
  const expect = { x: 1, b: 2 }
  t.deepEqual(result, expect)
})

index.ts test 2 does not pass

import test from 'ava'
import { getChanges } from '../src/comparisonHelpers.js'

test('getChanges - nested difference', (t) => {
  const oldData = { nested: { a: 1, b: 1, c: 1 } }
  const newData = { nested: { x: 1, a: 1, b: 2 } }
  const res = getChanges(oldData, newData)
  t.deepEqual(res, { nested: { x: 1, b: 2 } })
})

Basically, I expect nothing to be returned if the test passes, but this test returns this object upon failure:

{
      nested: {
  -     a: 1,
        b: 2,
        x: 1,
      },
    }

What am I doing wrong here that is stopping this test from passing?

Cheers!


Solution

  • Here is an extremely rough first pass at such a function (here named diff rather than getChanges):

    const isObject = (o) => 
      Object (o) === o
    const isEmptyObject = (o) => 
      isObject(o) && Object .keys (o) .length == 0
    
    const diff = (a, b) => 
      Object .fromEntries (
        [... (new Set ([...Object .keys (a), ...Object.keys(b)]))].flatMap (
          (k) =>
            k in a
              ? k in b
                ? isObject (a [k])
                  ? isObject (b [k])
                    ? [[k, diff (a [k], b [k])]]  // <--- recursive call here
                    : [[k, b [k]]]
                  : a[k] == b [k]
                    ? []
                    : [[k, b [k]]]
                : [[k, undefined]]
              : [[k, b [k]]]
        ) .filter (([k, v]) => !isEmptyObject(v))
      )
    
    
    const oldData = {nested: { a: 1, b: 1, c: 1 }, foo: {x: 3, y: 5}, bar: {x: 1}, qux: {x: 6}}
    const newData = {nested: { x: 1, a: 1, b: 2 }, foo: {x: 4, y: 5}, bar: {x: 1}, corge: {x: 6}}
    
    console .log (diff (oldData, newData))
    .as-console-wrapper {max-height: 100% !important; top: 0}

    This is very unsophisticated and there are inputs for which it will not work, most notably those which intentionally include an undefined value. It would also by design include the undefined value for keys missing from the new data. But it would be easy to not include them: Just change [[k, undefined]] to [] in the function and that would pass your test cases, I believe.

    Note that the answer Thank you (User) suggested uses a much nicer diff format than this: for all keys which have changed, it includes left and right properties to give you the value, skipping those which simply don't exist. This would let you unambiguously replay or revert the diff. With the format here, that won't always work.

    There are also in the flow here too many copies of the same output. I'm guessing with some thought, we might be able to reduce the cases involved.