Ruby on Rails - Gotchas

How to Gain a Few More Hours of Sleep

Ruby on Rails has become my goto web framework. I’m a big fan of the conventions, the gems, and the plethora of tutorials and resources. But like anyone, I’ve had my fair share of scrambling through stack overflow and scratching my head at curious behavior. Here are a handful of simple gotchas that will hopefully save you several hours of frustration.

  • Callback return values
  • Manipulating Strong Parameters
  • API usage: as_json, to_json
  • Postgres and the Array type

Callback Return Values

This is a common, even sinister, stumbling block because it fails so quietly. Often you’ll be hard-pressed to discover why your model isn’t saving, even though it passes your validations (model.valid? == true) and your attributes look sane.

Check whether any of your callback methods are inadvertently returning false – a common error, for example, when setting an attribute’s value to false, and the method that does so is registered as a before_save callback.

Canceling callbacks

If a before_* callback returns false, all the later callbacks and the associated action are cancelled. If an after_* callback returns false, all the later callbacks are cancelled.

Better yet, if you’re attempting to set default values, do so in your schema when generating the migration.


Manipulating Strong Parameters

Your controller needs to pre-process parameters before passing them to the model; capitalizing a string, arithmetic operation, sanitization, etc. Rails 4 introducted strong parameters to whitelist them for mass-assignment; for example, when you pass the params hash to model.update_attributes(). In Rails 4, you’d whitelist parameters accordingly:

controller.rb
1
2
3
4
5
6
7
8
def update
    @model.update_attributes(update_params)
end


def update_params
    params.require(:model).permit(:attribute_1, :attribute_2)
end

But modifying the params hash in the manner below will not work as expected.

controller.rb
1
2
3
4
5
/*a modified params[:attribute_1] will not be returned from this method*/
def try_to_update
    params[:attribute_1] = params[:attribute_1].capitalize
    params.require(:model).permit(:attribute_1, :attribute_2)
end

Paraphrased from the docs, the permit() method “returns a new ActionController::Parameters instance” – so the modified hash will never be returned, and you’ll keep wondering why your changes aren’t passed on.


API Usage: as_json, to_json

My recent sideproject, stereopaw, uses a Rails API to pass data back-and-forth between a Backbone app. Models are rendered in json, but it’s frequently not ideal to pass every attribute in a model back to the user-facing app; attributes like created_at, or updated_at, are frequently unnecessary, and it’s never a good idea to pass something like encrypted_password. To compactly exclude attributes of json’d models, overload the as_json() method in the model. When to_json() is called (implicitly, with render :json => @model ), it invokes as_json() to “serialize” the model into a json representation, and attributes can be filtered with the :except option.

/models/user.rb
1
2
3
4
5
6
/*exclude the attributes below when rendering a json User model*/

def as_json(options = {})
    options[:except] ||= [:encrypted_password, :email]
    super
end

On the contrary, some models might have methods you’d like to include when rendering the model in json.

/models/track.rb
1
2
3
4
5
/* json object includes track_previews() method results in the json'd track model*/
def as_json(options = {})
    options[:methods] || = [:track_previews]
    super
end

These are much more compact approaches to serializing json, as opposed to say, creating a new object and assigning attributes manually (eek).


Rails and the Postgres Array Data Type

Rails 4 and Postgres arrays aren’t quite the best of friends yet, and there are several caveats when using this data type that can leave you extremely frustrated.

Default Empty Array
The notation used to set a default empty array in migrations was a prior Rails issue, and may be problematic depending on your Rails version.

I’ve found that default: [] works for setting a default integer array type (defers to ActiveRecord to convert to postgres notation), while default ‘{}’ works for setting a default string array type – otherwise I’d incur a world of error messages.

/db/migrate/add_int_list_to_users.rb
1
2
3
4
5
6
7
/* for an integer array*/

class AddIntListToUsers < ActiveRecord::Migration
      def change
            add_column :users, :int_list, :integer, :array:true, default: []
      end
end
/db/migrate/add_string_list_to_users.rb
1
2
3
4
5
6
7
/* for a string  array*/

class AddStringListToUsers < ActiveRecord::Migration
      def change
            add_column :users, :string_list, :string, :array:true, default: '{}'
      end
end

Passing (Empty) Array Parameters
When expecting a parameter of an array data type, map the key to an empty array.

controller/test.rb
1
2
3
   def new_params
       params.require(:model).permit(:your_array => [])
   end

Sometimes you’ll need to pass an empty array, but Rails will exclude empty values, and the key won’t exist in the params hash. You’ll have to modify your parameter values accordingly, and a good bet is via “abbreviated assignment”.

controller/test.rb
1
2
3
4
5
   def new_params
       mod_params = params.require(:model).permit(:your_array => [])
       /* assign an empty array given the param value doesn't exist */
       mod_params[:your_array] ||= []
   end

ActiveRecord Dirty Array
Sooner or later you’ll encounter your model failing to save a modified array attribute. When it comes to array.push, array.pop, ActiveRecord doesn’t mark the array as “dirty,” and subsequent changes using these array manipulations will not propogate to the database. You’ll need to flag the specific attribute as dirty by calling attr_name_will_change! if you want to save the updated array type attribute.

models/some_model.rb
1
2
3
4
5
6
7
8
9
10
def update_array_example
      /*this will not update*/
      self.ip_addresses.push("192.168.0.1")
      self.save

      /*but marking the attribute as dirty will save*/
      self.ip_addresses.push("127.0.0.1")
      self.ip_addresses_will_change!
      self.save
end

Hope all this saves you a few headaches!

Comments