Sometimes I Amaze Myself: Bulk Tagging doesn't have to be hard

In the first of, what I'm sure will be many posts entitled "Sometimes I Amaze Myself", I'll be sharing a piece of amazing code I wrote.

I'm working on a large CMS for a company named [REDACTED]. One of the things we're currently implementing is tagging. We've got something like 100 different entities (that might be a bit of an exaggeration) and every single one of them needs tags.

Not only does each individual entity record need tag support, but there's also call for bulk tag manipulation, and searching for tags and "anti-tags".

To give you some code representation, a simple customer schema, with a simple address schema, might look like this:

Customer: {  
    id (integer, optional): Required for updates, ignored on inserts.,
    accountId (integer, optional),
    email (string, optional),
    company (string, optional),
    address (Address, optional),
    firstName (string, optional),
    lastName (string, optional),
    displayName (string, optional),
    tags (string, optional)
}
Address {  
    id (integer, optional),
    address1 (string),
    address2 (string, optional),
    city (string),
    state (string, optional),
    postalCode (string),
    country (string),
    tags (string, optional)
}

The tags are stored in the db for each entity as a space delimited string (not my call). To make manipulation and display of tags easier, I've created an http response interceptor that scans the response data for a tags property. A simple version of this is as follows:

response: function(response) {  
  // tags come back from the api as a space delimted string.
  // parse them into an array if they are found.
  if(!!response.data.tags) {
      if (typeof response.data.tags === 'string') {
          response.data.tags = response.data.tags.split(' ');
      }
  }

    if(!!response.data.rows) {
        response.data.rows.forEach(function(row) {
            if(typeof row === 'object' && row.hasOwnProperty('tags') && typeof row.tags === 'string') {
                row.tags = row.tags.split(' ');
            }
        });
    }
}

So, now, anytime we have a response with an object that has tags in it, they are translated to an array for our convenience.

Let's say we have a controller that has a collection of all of our customers, a model bound to our bulk tag input, and a model bound to our tag search input on the $scope.

app.controller('CustomerController', function($scope) {  
    $scope.customers = CustomersService.query();
    $scope.bulkTagList = '';
    $scope.searchTagList = '';
}

Searching Tags

I didn't want to create two inputs, one for tags and one for anti-tags, so I decided that to search for anti-tags, you'd preface and anti-tag with a '-'. For instance, I want to search for all customers that are tagged "dog" but not tagged "shark", my $scope.searchTagList contents might look like this:

$scope.searchTagList = 'dog -shark';

Great. But my server API is expecting the following inputs to search for a customer:

tags (string, optional)  
antiTags (string, optional)  

Which means I have to translate my $scope.searchTagList into an object with two properties: tags & antiTags.

Enter: TagService

tag-service.js is my utility script for tag modification. The first function I wrote for it was splitTags:

/**
     * Splits tags into tag and antiTag components
     * @param  {[Array<string>]} tagList An array of space-delimited strings 
     * @return {[object]}         An object containting tags and antiTags arrays
     */
    splitTags: function(tagList) {
      var tags, antiTags;
      tags = _.filter(tagList.split(' '), function(tag) {
        return tag.indexOf('-') !== 0;
      });

      antiTags = _.filter(tagList.split(' '), function(tag) {
        return tag.indexOf('-') === 0;
      });

      _.forEach(antiTags, function(tag,index) {
        antiTags[index] =  tag.substring(1);
      });
      return {tags: tags, antiTags: antiTags};
    }

It's fairly self-explanatory and is called thusly:

var splitTags = TagService.splitTags($scope.searchTagList.split(' '));  

splitTags now looks like this:

splitTags = {  
    tags: ['dog'],
    antiTags: ['shark']
}

I can now call my search API:

$scope.customers = CustomerService({tags: splitTags.tags.join(' '), antiTags: splitTags.antiTags.join(' ')};

Everything works as expected.

Bulk Tagging

I've added another method to my TagService to apply tags to a collection:

 /**
     * Apply tags to a collection
     * @param  {Array} collection An Array of items to apply tags to
     * @param  {string} tagList    A string, space delimited, of tags to apply
     * @param  {string} path       The path to the tags property of each collection item. (default: 'tags')
     * @return {null}            no return
     */
    applyToCollection: function(collection, tagList, path) {
      path = path || 'tags';
      var splitTags = this.splitTags(tagList);

      _.forEach(collection, function(item) {
        PathService.setPath(item,path, _.difference(_.union(PathService.getPath(item,path), splitTags.tags), splitTags.antiTags));
      });
      console.debug(collection);
    }

This method spins through a given collection, calls our Path-Service (which we'll talk about momentarily, and then sets each item in the collection's tags property to an array that contains a difference between the union of the item's current tags and the tags added, and the antiTags added. That's all very confusing, so I'll break it down step by step:

var unionTags = _.union(PathService.getPath(item,path), splitTags.tags)  

unionTags will now contain an array consisting of all of the old tags the item had plus all of the new -positive- tags that were in the bulkTagList.

var differenceTags = _.difference(unionTags, splitTags.antiTags);  

differenceTags will now contain an array consisting of tags that did not exist in both unionTags and splitTags.antiTags

So, if our customers array looked something like this:

$scope.customers = [
    {
        tags: ['foo','bar']
    },
    {
        tags: ['foo','baz']
    }
    {
        tags: ['bar','baz']
    }
]

And we did the following:

$scope.bulkTagList = 'foo -bar';
TagService.applyToCollection($scope.customers, $scope.bulkTagList);  

$scope.customers would end up looking like this:

$scope.customers = [
    {
        tags: ['foo']
    },
    {
        tags: ['foo','baz']
    }
    {
        tags: ['foo','baz']
    }
]

But wait, what is PathService?

PathService: The magic continues

You'll notice a reference to a PathService (included at the bottom of this article). This is a magic service that I wrote to get and set deep-properties on an object. It has two methods: getPath & setPath. The getPath method returns the value of a deep-property on an object. Let's say, you wanted to get the address's tag property of a customer. You could do the following:

var tags = customer.address.tags;  

Simple enough.

What if you wanted to get them for each item in a collection:
var tags = [];

_.forEach($scope.customers, function(customer) {  
    tags.push(customer.address.tags);
});

Also very easy.

But let's say you wanted to have a reusable block of code that did this for generic collections?

function getTags(collection) {  
    var tags = [];
    _.forEach(collection, function(item) {
        tags.push(item.tags);
    });
}

That's correct logical place to go. But what if each collection has the tags at a different level, or a collection has multiple entities, each with it's own tag property. That's where deep-object properties help us create a reusable block of code.

Using deep-properties, we can write the following:

function getTagsFromCollection(collection, tagPath) {  
    tagPath = tagPath || 'tags';
    var tags = [];
    _.forEach(collection, function(item) {
        tags.push(PathService.getPath(item, tagPath));
    });
}

Now, we can call getTagsFromCollection with a different collection and tagPath each time. Lets say we wanted to get the tags from each customer's address property.

var addressTags = getTagsFromCollection($scope.customers, 'address.tags');  

Boom. Done.

PathService has us covered for that.

Now what if we want to set an object's deep-property, well, PathService has that covered as well.

var customer = $scope.customers[0];  
PathService.setPath(customer, 'address.tags', ['foo','bar','baz']);  

Again, in this instance, it would have been easier to just set customer.address.tags = ['foo','bar','baz'] but we're going for repeatable here.

The Prestige

So, Bulk Tagging is the reason we're talking. Let's say our very simplified controller looks like this:

angular.module('app').controller('CustomerController', function($scope) {  
    $scope.customers = CustomerService.query();
    $scope.bulkTagList = 'dog -shark -fish cat';
});

With this setup, we're going to modify all of customers to add the dog and cat tag and remove the shark and fish tags. So, what's the one line of code that will do that?

TagService.applyToCollection($scope.customers, $scope.bulkTagList);  

That's it. That's all there is to it.

Each of our customers now has,in addition to whatever tags they originally had, both the dog and cat tags and are now all missing the shark and fish tags.

But what if the tags exist at a different heirarchical level, or we want to modify the tags of a different entity in the collection?

Simple:
``

TagService.applyToCollection($scope.customers, $scope.bulkTagList,'address.tags');  

Done.

There's really nothing more to explain.

It's over.

And, as promised:

// PathService.js
angular.module('app').factory('PathService', function() {  
  return {
    /**
     * Get an object's deep property
     * @param  {object} obj  The object containing the desired deep property
     * @param  {string} path A string representing the deep property required 'foo.bar.baz'
     * @param  {*} def  the default value to return if the property is not found
     * @return {*}      The value requested
     */
    getPath: function(obj, path, def){
      for(var i = 0,parts = path.split('.'),len = parts.length; i < len; i++){
        if(!obj || typeof obj !== 'object') {
          return def;
        }
        obj = obj[parts[i]];
      }

      if(obj === undefined) {
        return def;
      }
      return obj;
    },
    /**
     * Set's an object's deep property value to the given value
     * @param {object} obj   The Object to contain the desired deep property value
     * @param {string} prop  A string representing the deep property required 'foo.bar.baz'
     * @param {*} value The value to so the object's deep property to
     */
    setPath: function(obj, prop, value) {
      if (typeof prop === 'string') {
        prop = prop.split('.');
      }
      if (prop.length > 1) {
        var e = prop.shift();
        this.setPath(obj[e] = Object.prototype.toString.call(obj[e]) === '[object Object]' ? obj[e]: {},
          prop,
          value);
      } else {
        obj[prop[0]] = value;
      }
    }
  };
});