This is another one of those geeky posts…reader be warned!
A project that we’ve been working on behind the scenes has gradually become more complex. What started out as just a few resources, has now grown into twenty-four different resources…imagine all the duplication!
In fact, I took this snapshot of the application right before I did some cleanup (output below trimmed):
zack-huberts-computer:~/workspace/thecity zhubert$ rake stats
+----------------------+-------+-------+---------+---------+-----+-------+
| Name | Lines | LOC | Classes | Methods | M/C | LOC/M |
+----------------------+-------+-------+---------+---------+-----+-------+
| Controllers | 2626 | 1860 | 27 | 202 | 7 | 7 |
As you can see, the Controllers were pretty hefty with 2626 lines total. Ouch.
Inspired by a pretty old blog post, I decided to take a stab at metaprogramming to clean up this mess. I think it was a success:
zack-huberts-computer:~/workspace/thecity zhubert$ rake stats
(in /Users/zhubert/workspace/thecity)
+----------------------+-------+-------+---------+---------+-----+-------+
| Name | Lines | LOC | Classes | Methods | M/C | LOC/M |
+----------------------+-------+-------+---------+---------+-----+-------+
| Controllers | 1340 | 1006 | 28 | 123 | 4 | 6 |
So what does the superclass look like? Like this:
# This is a CRUD controller for nested routes.
class CRUDController < ApplicationController
before_filter :crud_setup_nested_routes
def self.named_filter( filter_hash )
filter, function = filter_hash.to_a[0]
@filter_store ||= Hash.new {|a,k| a[k] = Array.new }
@filter_store[filter] << function
end
def self.named_parents(parents_array)
@parents ||= Array.new
@parents << parents_array
@parents.flatten!
end
def parents
if self.class.instance_variable_get("@parents").nil?
self.class.instance_variable_set("@parents", Array.new)
end
self.class.instance_variable_get("@parents")
end
def named_filter_store
if self.class.instance_variable_get("@filter_store").nil?
self.class.instance_variable_set("@filter_store", Hash.new {|a,k| a[k] = Array.new } )
end
self.class.instance_variable_get("@filter_store")
end
def named_filter(name)
named_filter_store[name].each do |filter|
case filter
when nil
# do nothing
when Proc
filter.call
when Symbol
(Proc.new { send filter }).call
else
raise("PANIC! Named Filter is being called on something unusual.")
end
end
end
# These are the standard CRUD functions, appropriately made generic
def index
named_filter(:index)
@object = instance_variable_set("@#{plural_name}", current_model.find(:all, :conditions => conditions_string))
respond_to do |format|
format.html # index.rhtml
format.xml { render :xml => @object.to_xml }
format.js
end
end
def show
named_filter(:show)
respond_to do |format|
format.html # show.rhtml
format.xml { render :xml => @object.to_xml }
format.js
end
end
def new
named_filter(:new)
instance_variable_set("@#{singular_name}", current_model.new)
end
def edit
named_filter(:edit)
end
def create
@object = instance_variable_set("@#{singular_name}", current_model.new(params_hash))
named_filter(:create)
unless parents.size == 0
@object["#{parents[-1]}_id"] = params["#{parents[-1]}_id".to_sym]
end
respond_to do |format|
if @object.save
flash[:notice] = "#{singular_name} was successfully created"
named_filter(:after_save_success)
format.html { redirect_to @hook_send_to_after_create || send_to_singular_name_url }
format.xml { head :created, :location => @hook_send_to_after_create || send_to_singular_name_url }
format.js
else
format.html { render :action => "new" }
format.xml { render :xml => @object.errors.to_xml }
format.js
end
end
end
def update
named_filter(:update)
respond_to do |format|
if eval("@#{singular_name}").update_attributes(params_hash)
named_filter(:after_update_success)
flash[:notice] = "#{singular_name} was successfully updated"
format.html { redirect_to @hook_send_to_after_update || send_to_singular_name_url }
format.xml { head :ok }
format.js
else
format.html { render :action => "edit" }
format.xml { render :xml => @object.errors.to_xml }
format.js
end
end
end
def destroy
named_filter(:destroy)
@object.destroy
respond_to do |format|
format.html { redirect_to @hook_send_to_after_destroy || send_to_plural_name_url }
format.xml { head :ok }
format.js
end
end
private
# do name handling
def plural_name
controller_name.gsub('_controller','')
end
def singular_name
plural_name.singularize
end
# model naming
def current_model
Object.const_get singular_name.classify
end
def params_hash
params[singular_name.to_sym]
end
# redirect urls, should probably genericize for non-redirect blocks
def send_to_singular_name_url
parent_array = build_parent_array
parent_array << @object
send("#{singular_name}_url",*parent_array)
end
def send_to_plural_name_url
parent_array = build_parent_array
send("#{plural_name}_url",*parent_array)
end
# cleanly build the parent array for the send()
def build_parent_array
parent_array = []
for parent in parents
parent_model = Object.const_get parent.classify
parent_array << parent_model.find(params["#{parent}_id".to_sym])
end
return parent_array
end
def conditions_string
case parents.size
# SQL doesn't like it when you indicate a condition and don't pass one, so
# true is passed when you don't really want a condition
when 0
con = ["1 = 1"]
else
con = "#{parents[-1]}_id = " + params["#{parents[-1]}_id".to_sym]
end
end
# pulls together all the nested routes
def crud_setup_nested_routes
for parent in parents
parent_model = Object.const_get parent.classify
instance_variable_set("@#{parent}", parent_model.find(params["#{parent}_id".to_sym]))
end
if params[:id]
@object = instance_variable_set("@#{singular_name}", current_model.find(params[:id], :conditions => conditions_string))
end
named_filter(:setup)
end
end
Kudos to David Parrott, one of the faithful developers that volunteers here at Mars Hill, for the metamagic necessary to make this more Rail-ish with the named_filter paradigm.
What does a controller look like that uses this? Much more readable as only the overrides stick out…everything else is therefore convention!
# RESTful Resource, the Invitations controller handles all aspects of CRUD for
# the Invitation resource and belongs to 'group'
class InvitationsController < CRUDController
named_filter :setup => :allow_bypass_with_magic_code
named_filter :create => :create_code_and_be_secure_about_it
named_filter :after_save_success => :redirect_to_invitations
named_parents ['group']
def allow_bypass_with_magic_code
...
end
def create_code_and_be_secure_about_it
...
end
def redirect_to_invitations
@hook_send_to_after_create = invitations_url(@group)
end
end
Or if everything is standard, as simple as:
class NewsItemsController < CRUDController
named_parents ['group']
end
Following the development of this, I’ve found that there are some similar things out there, but this approach seems pretty good to me. Being able to read the first five lines of the Controller and get the big picture is very handy, especially as the project grows.