Wednesday, March 7, 2012

Dynamic attributes and MongoDB/Mongoid

I recently started learning more about MongoDB, and how to use it in Rails 3.1 with the Mongoid gem. MongoDB is not your typical relational database; It is considered a NoSQL database because it stores your data in one huge JSON document instead of a bunch of relational tables. Actually, instead of storing the data in JSON, it stores it in BSON (Binary JSON) for performance reasons. The really cool thing about MongoDB is that it is schema-less, which allows you to store non-homogeneous objects in your database.

Why is this cool you might ask? Well let me explain my problem; I want to create a web service that allows CRUD operations on objects called Events. Events have a name, start_time, and most importantly a game. Depending on the game attribute, each Event will have different game specific attributes. For example, I have an Event with a game value of 'starcraft2'. Only Starcraft 2 Events have a region and a league attribute, while League of Legends Events have a min_elo attribute. How can we store these Event objects that have different attributes in our one document? We could do this in a standard MySQL database, but our table would be very wide, meaning we would have to have a column for every game specific attribute. This isn't very ideal because for each row, there would be a bunch of columns that wouldn't be used depending on the game.

The great thing about Mongoid is that it supports dynamic attributes. I quote this from their website, "By default Mongoid supports dynamic fields - that is it will allow attributes to get set and persisted on the document even if a field was not defined for them." This sounds like exactly what we need. We can just define the common Event attributes in the model itself, and then specify the game specific attributes at run time. So how do we go about doing this? Well, let's start with the Event model:
class Event
   include Mongoid::Document

   field :name, type: String
   field :start_time, type: DateTime
   field :game, type: String
end
This is pretty straight forward. Now the way I chose to define game specific attributes is by defining them in a YML file. I created a file called dynamic_fields.yml and put it in the Rails.root/config folder. Here is how I defined it:
starcraft2:
   attributes:
      regions:
         name: regions
      leagues:
         name: leagues
You can define your dynamic attributes however you want, but this is just how I decided to do mine. So now that we have our Event model, and our Starcraft 2 attributes, how do we set these when an Event gets created, and how do we make sure these attributes only get set if the game is Starcraft 2? First we need to understand what mass assignment is, and how it could be a security vulnerability. Here is an excellent RailsCasts on that topic, but the gist of it is that when the server receives a POST request to create a new Event, it will take the parameters it receives and mass assign themto the matching attributes of the Event model. For example, if we had a User model, and a is_admin attribute, the user shouldn't be able to set the value of the is_admin attribute. If they could, then everyone could make themselves an admin when they create a new account. So how do we prevent this? In Rails there is a very useful method called attr_accessible that we can set on attributes in our model. Only the attributes you set using attr_accessible will be available for mass assignment. So let's do that for our Event model for all the common fields:
class Event
   include Mongoid::Document

   field :name, type: String
   field :start_time, type: DateTime
   field :game, type: String

   attr_accessible :name, :start_time, :game
end
This reason this is so important, especially in Mongoid is that since we enabled dynamic attributes, a user can create an Event with any additional attributes they want. They can add an attribute called "some_random_field" by including it in the POST parameters and it would be inserted in our database no questions asked. By setting attr_accessible on name, start_time, and game we are saying users can only set the values for these attributes and that's it. But there's a problem now, how do we set the values for our game specific attributes?

Good thing for us, we can dynamically set attr_accessible thanks to this great RailsCast here. I'd recommend just watching the RailsCast to learn how to do this, but I want to mention that there are a couple changes that need to be made. The RailsCasts was filmed using Rails 3, but now that Rails 3.1 is out, we need to make some minor changes.
class Event
   include Mongoid::Document

   field :name, type: String
   field :start_time, type: DateTime
   field :game, type: String

   attr_accessible :name, :start_time, :game

  # :accessible is a variable used to store the game specific dynamic fields so that they can be set through mass assignment via attr_accessible. this allows the attr_accessible to be set dynamically.
  attr_accessor :accessible

  private
  # Overrided so that extra variables can be added to the attr_accessible dynamically to allow mass assignment.
  def mass_assignment_authorizer(role = :default)
    super + (accessible || [])
  end
end
We can now use the accessible variable in the EventsController to dynamically allow mass assignment of game specific attributes. The accessible variable is actually an array, and anything we put in that array will be added to attr_accessible since we overrided the mass_assignment_authorizer method.

One last detour before we get to the final step. We first need to figure out how load the YML file that contains our dynamic attributes in to memory so that we know what attributes to add to attr_accessible. I want to load the dynamic attributes in to a global variable so that we can use it during the lifetime of the server. To do this, we can create a file called event_config.rb in our Rails.root/config/initializers folder. Whenever the rails server starts up, it will automatically load all the files in the initializers folder. We can load the YML in to memory by calling YAML.load() and passing it a file.
module EventConfig
  DYNAMIC_FIELDS = YAML.load(File.open("#{Rails.root}/config/dynamic_fields.yml"))
end
Great, now we can access the DYNAMIC_FIELDS variable anywhere in our code. We have all the tools we need, so let's just jump right in to it. The DYNAMIC_FIELDS is a hash of what is defined in the YML file. So we can iterate through it by accessing the 'starcraft2' key, and then that will return another hash. Inside the 'starcraft2' hash we can acess the 'attributes' key, which will give us another hash that contains the attributes we want. Then we can just iterate through the attributes hash's keys to get the attributes we want to set to be mass assignable.
def create
   # we don't want to pass in the params right away because 
   # we have to set the accessible variable first
   @event = Event.new

   # the keys of the attribute hash are the game specific attributes 
   # we want to be assignable, so set the accessible variable to them
   game = params[:event][:game]
   @event.accessible = EventConfig::DYNAMIC_FIELDS[game]['attributes'].keys

   # lastly, set all the other attributes for the event using the params
   @event.attributes = params[:event]

   respond_to do |format|
      if @event.save
        format.html { redirect_to @event, notice: 'Event was successfully created.' }
      else
        format.html { render action: "new" }
      end
    end
end
That's it! It should work now, but how can we test this really easily? We can test it by using a tool called curl, which allows you to generate and send HTTP requests from the console. The most common use case will be a user filling out a form to create an Event on our server. Curl has a -F parameter, which we can use to emulate a filled-in form and it will send a HTTP POST request to the server.
curl -F 'event[name]=curlTest' -F 'event[game]=starcraft2' -F 'event[regions]=North America' -F 'event[leagues]=Grandmaster' 'http://localhost:3000/events'
This should successfully create a new Event object in our database! We can also test to make sure that our attr_accessible is working correctly by not letting users set attributes that they shouldn't be setting. For example, we can try this:
curl -F 'event[name]=curlTest' -F 'event[game]=starcraft2' -F 'event[regions]=North America' -F 'event[leagues]=Grandmaster' -F 'event[some_random_field]=should not get assigned' 'http://localhost:3000/events'
In the rails server log, you should see this error message, "WARNING: Can't mass-assign protected attributes: some_random_field." And we can see that it did create the Event, but without the some_random_field attribute.

That's it, you are all set and ready to go! I hope that this post was helpful in understanding how dynamic attributes can be a cool and useful feature for your projects. Good luck, and until next time.

2 comments:

  1. Wonderful article! Thanks a lot for it.

    ReplyDelete
  2. Thanks for this helpful article!

    Your article inspired me for my own solution with "role-based" dynamic attributes by using an initializer instead of putting "mass_assignment_authorizer" directly in the model.

    http://sweo.de/web-engineering/ruby-rails/rails-mongoid-dynamic-attraccessible-massassignmentauthorizer

    Maybe its helpful for someone.

    ReplyDelete