Oleksiy's Blog

Working with tree objects in JavaScript

04/04/2015

During recent refactoring of the core of my job project, I became really sad when i found these kinds of piece of... code:

storedData.events[requestData.date] = {};
storedData.events[requestData.date][requestData.categoryId] = {};
storedData.events[requestData.date][requestData.categoryId].data = result;
storedData.events[requestData.date][requestData.categoryId].updated = currentTime;
_deferred.resolve(storedData.events[requestData.date][requestData.categoryId].data);

These kinds of blocks were dispersed throughout the application. The structure of nesting this caching tree was different for different cases, so I came up with idea to create a lean and flexible interface for accessing this object.

So by using TDD, I expected to have a method store() which takes dynamic set of arguments, the last of ones will contain the data. E.g.:

cache.store('events', 'today', 16, ['event1', 'event2']);

should store the data and set the timestamp:

{ events: { 
  today: { 
    16: {
      data: ['event1', 'event2'] ,
      timestamp: 1428089159822
    }
  }
} }

I broke down this task to several simpler ones and defined them as functions: pack() - should produces nested tree object of merge() - should recursively merge from source object to target object dive() - should recursively get the value by provided path

As utility I used UnderscoreJS.

Tests first

Alright, so I based on that wrote the tests:

it('should pack to hierarchical structure', function() {
    expect(priv.pack(['events', 'today', 16, ['event1', 'event2']])).
      toEqual({
        events: {
          today: {
            16: {
              data: ['event1', 'event2'],
              updated: mocks.timestamp
            }
          }
        }
      });
  });

  it('should store events data', function() {
    pub.store('events', 'today', 16, ['event1', 'event2']);
    pub.store('events', 'tomorrow', 150, ['event3', 'event4']);
    pub.store('event', 12345, { name: 'Event 5'});

    expect(pub.storedData.events).
      toEqual({
        today: {
          16: {
            data: ['event1', 'event2'],
            updated: mocks.timestamp
          },
        },
        tomorrow: {
          150: {
            data: ['event3', 'event4'],
            updated: mocks.timestamp
          },
        }
      });

    expect(pub.storedData.event).toEqual({
      12345: {
        data: { name: 'Event 5'},
        updated: mocks.timestamp
      }
    });

  });

Nested object from array

function store () {
  var args   = [].slice.apply(arguments), // to array
      source = pack(args);
  merge(storedData, source);
  return source;
}

// creates hierarchical structure and stores last array element as data
function pack (args) {
  var args = args.slice(0),
      obj = {}, arg = args.splice(0,1)[0];

  obj[arg] = args.length > 0 ? pack(args) : undefined;
  return obj[arg] ? obj : new Cached(arg);
}

Deep Merge

function merge (target, source) {
  _.each(_.keys(source), function (key) {
    target[key] = source[key] instanceof Cached ?
      source[key] : target[key] || source[key]; // change if endpoint otherwise choose an existing
    merge(target[key], source[key]);
  });
}

I also created a constructor of cached object to distinguish it in setting and getting:

function Cached (data) {
  _.extend(this, {
    data: data,
    updated: timeFactory.getCurrentTime()
  });
}

For accessing the database i needed a stored() method which recursively travels by object by path given in arguments. This function was slightly simpler to implement.

So I created dive() function and left the business stuff in facade stored() to make it syntactically sweeter.

function stored () {
  var args   = [].slice.apply(arguments),
      cached = dive(args, storedData);
  return cached && cached instanceof Cached ?
    cached.data : undefined;
}

function dive (args, obj) {
  var args = args.slice(0),
      key  = args.shift();
  return !obj ? undefined :
    args.length > 0 ?
      dive(args, obj[key]) : obj[key];
}

Then I added more tests:

  it('should set and get data from/to cache', function() {
    pub.store('events', 'today', 16, ['event1', 'event2']);
    pub.store('events', 'tomorrow', 52, ['event3', 'event4']);

    expect(pub.stored('events', 'today', 16)).
      toEqual(['event1', 'event2']);

    expect(pub.stored('events', 'tomorrow', 52)).
      toEqual(['event3', 'event4']);

    expect(pub.stored('events', 'live', 10)).
      toBeUndefined();

    expect(pub.stored('some', 'invalid', 'path')).
      toBeUndefined();
  });

And this is really helpful thinking in TDD. While hacking my module, I realized some cases that my algorithm didn't do because of silly mistakes. Like this one:

  it('should override existing data', function() {
    pub.store('event', 100, {name: 'old data'});
    pub.store('event', 100, {name: 'new data'});
    pub.store('event', 101, {name: 'other data'});
    expect(pub.stored('event', 100).name).toEqual('new data');
    expect(pub.stored('event', 101).name).toEqual('other data');
  });

In passion of syntactic perfection and optimization, I missed the basic thing: events had not been updating by store() function. Though UT helped me out, as always, I resolved that by adding instance checking merge() function.

Now after adding Cached() constructor it works as just fine.

Although, big refactoring is still in progress...