June 9, 2010
logical_tabs is a Rails plugin that assists with the creation of a tabbed panel interface. It has a number of advantages over other solutions, but the primary one is that the tabs persist: if you reload or revisit a page with a tabbed panel, it will remember which tab you last had open on that page. There is a demo project available that shows how to use it with both ERB and HAML templates.
logical_tabs works with both Prototype and jQuery. Here's a screenshot of it in use on a social networking site:

It's still a little rough and I have future plans for it, but I'm using it in production on three sites so I figured I'd give it an early release while I'm hacking at RailsConf 2010.
Why build a new tool, when others exist?
There are already other solutions for building tabbed interfaces, most notably the jQuery UI Tabs tool. Another solution, which I had used previously, is railstabbedpanel.
The jQuery UI tool still requires you to construct all the HTML yourself which can be tedious, and it doesn't work for the large number of Rails products out there using Prototype rather than jQuery. railstabbedpanel provides HTML helpers, but the rendering code is heavily dependent on the specifics of the ActionView rendering/capturing code and so kept breaking when Rails was upgraded, or when HAML was upgraded. I found it very difficult to keep it working as I upgraded my toolset. Also, RTP's javascript was not unobtrusive.
Moreover, neither solution persists tab selections across page loads. So, I wrote this plugin which provides all of these advantages:
- Simple helpers to make it easy to build semantic HTML structures for the tabbed panels.
- Helper rendering that is isolated from the internals of ActionView and shouldn't break when Rails or HAML are upgraded.
- Javascript for both Prototype and jQuery environments
- Persistence of tab selection across page loads - revisit a page, get the same tab.
- Unobtrusive javascript.
Installing logical_tabs
logical_tabs is currently in version 0.6 and only supports Rails 2.x. I have only tested it in Rails 2.3.x, but it should work in 2.2 and 2.1 ... let me know if it doesn't.
Install the plugin:
> script/plugin install http://LRDesign@github.com/LRDesign/logical_tabs.git
Copy in the appropriate javascript file:
If your project uses Prototype:
> cp vendor/plugins/logical_tabs/javascripts/logical_tabs_prototype.js public/javascripts
If your project uses jQuery:
> cp vendor/plugins/logical_tabs/javascripts/logical_tabs_jquery.js public/javascripts
Copy stylesheets (optional)
logical_tabs includes starter stylesheets in both css and sass, you can find them in vendor/plugins/logical_tabs/stylesheets; copy the appropriate file to your public/stylesheets directory and include it in your application, or create your own.
Using the view helpers
logical tabs gives you a view helper, create_tabbed_panel, which takes a block with an argument - that argument will be a TabbedPanel object, the code for which can be found in vendor/plugins/logical_tabs/lib/logical_tabs/tabbed_panel.rb. This object is responsible for tracking all the tabs and their content, generating appropriate element IDs and the like.
There is example code - both ERB and HAML - in the logical_tabs_demo demo project, but the basic usage looks like this. Individual tab/pane pairs are generated simultaneously and can take their content either as a block or as a :content => "content" option.
<% create_tabbed_panel do |tabbed_panel| %>
<% tabbed_panel.add_tab("Tab One") do %>
Content for Tab One
<% end %>
<% tabbed_panel.add_tab("Tab Two") do %>
Content for Tab Two
<% end %>
<% tabbed_panel.add_tab("Tab Three", :content => "<p>Content for tab three</p>") %>
<% end %>
When you close the block for create_tabbed_panel, the object will render the HTML for all tabs you have added. It will generate a div with class="tabbed_panel that contains two UL lists; one for the tabs and one for their associated content panels.
That's about it. If you have the correct javascript file for your environment installed, it should just work.
Other Options
logical_tabs generates a default format for the IDs and classes for generated; it uses this to track multiple panels on the same page, for example. However, you can override the generated IDs if you want them formatted in a specific way for custom javascript you've written:
<%= create_tabbed_panel(:base_id => "my_panel") do |panel| %>
...etc...
Will use "my_panel" as the ID of the outer div and as the prefix for the individual IDs of the sub-tabs.
logical_tabs always uses the ID of the outer div as the prefix for all internal components, to guarantee that you don't have duplicate IDs (i.e. two LIs with id='tab_1') if you have multiple panel structures on the page.
Tab Persistence
When using tab interfaces in the past, one of the most common complaints I heard from clients is that the panel would "forget" which tab they had selected when visiting a previous page. The two cases where this was most frequently a problem were:
- When the panels contained forms. If you submit a form but the data fails validation, you may be returned to the same view but with the wrong panel selected, so the user can't see the error messages or the color-coded input fields. This is extremely confusing.
- When the panels list items that the user may want to edit on another page. For example, one client has a tabbed panel in a project management system that lists a project's work orders, action items, and problem reports in separate tabs. If the user clicks to edit a problem report, when finished they may be redirected back to the project page, but now the list of work orders is the active tab - they can't see the list containing the item they just edited. Ouch.
The solution I chose was to store the tab selection in a cookie. logical_tabs stores the current tab selection for each tabbed panel structure (in case you have more than one) along with the url, and at each page load the JS it checks to see if there was previously a tab selected.
Caveats
In some ways, logical_tabs is still a bit of a weekend hack. In particular, I'm not thrilled with the ugly element ID formats, and the jQuery script is not a proper jQuery plugin, doesn't interface with jQueryUI, and isn't themeable with themeroller.
Most of my projects use Prototype, and Logical Reality is only recently beginning to switch to jQuery for new projects. So, the adaptation of the javascript for logical_tabs to jQuery was a very quick hack - my best attempt to clone the Prototype behavior with jQuery selectors. If you think it's ugly, it is, and that's why.
Future Plans
- Clean up the jQuery and make it a properly-behaved jQuery UI plugin skinnable with themeroller.
- Rails 3 compatibility.
June 8, 2010
Introducing Convection, an open-source (MIT License) project of Logical Reality Design. Need to swap files with clients or collaborators, but don't want to (or can't) trust those files to Amazon or sendbigfiles.com? Want fine-grained control over which users can see which files? Try Convection.
Lots of file exchange services exist, for example SendBigFiles.com etc. However, all of these services are hosted on someone else's hardware, and most of them share files by transferring URLs -- usually via email -- without good access control or authorization schemes.
We built Convection because a client needed to transfer files with other companies, but they needed to host the system themselves because the contracts they hold with their own clients require them not to store data on services that they don't control. The specifications Convection was built around were:
- Hosted on our own server.
- Downloads require a login, and files cannot be shared by email.
- Users must log in to download files or see available files.
- User accounts can be grouped, groups can be managed.
- Files can be shared with an entire group.
- Files uploaded by users default to minimal permission - visible only to the uploader and to admins.
- All communications over SSL. (we made this optional)
Installing and hosting Convection
To run Convection, you will need a webserver capable of running a Ruby on Rails application, and a database. Setting such a thing up is beyond the scope of this post. If you have a Dreamhost account, you can set up a Rails-capable domain with a couple of clicks in their web panel. In addition to the server, you will need to set up a database (we have only tested MySQL, but Convection should work with any SQL database for which Rails/ActiveRecord has a supported adapter, including PostgreSQL and Oracle), and initialize the database with these two commands:
> rake db:migrate RAILS_ENV=production
> rake db:seed RAILS_ENV=production
This will generate the tables necessary for Convection to run, and create a pair of initial demo users "admin" and "user", both with password "foobar".
If you are setting up a server yourself, there are plenty of guides to deploying Rails on the web. Much of our own guide to deploying CruiseControl.rb can be used to set up any Rails application on Slicehost or any other Ubuntu Linux hosting provider.
Let me know if you're trying to deploy Convection and having trouble: if we know people are using it we may put effort into making it easier to deploy and install, and write a more thorough guide.
A few other links that may help you with deploying a Rails application, depending on your environment:
- Using Phusion Passenger to Deploy a Rails Application on Apache
- Deploying Rails Applications (book)
If you Google around you may find plenty of other links relevant to your particular environment.
Configuring Convection
If you log into your running Convection application as an administrator (initial user "admin", password "foobar"), an Admin Tools utility will appear in the right hand column. From here, you can access tools for creating users, and groups, and the general site configuration.
In general site config, you can set your site name and logo, set whether or not the site requires SSL access (Note: your server must already support SSL!) outgoing email and email notification preferences, add Google analytics, and an assortment of other site configuration operations that are mostly self-explanatory.
Upload progress bar: experimental feature.
If your site hosts large uploads that take a while to transfer, you can try our experimental tools to provide an upload progress bar to the user. This tool will only work if your site is served by Apache, and requires installing and configuring an optional module for Apache.
To enable this tool, follow the instructions in the README file and associated links, and turn on the progress bar setting in site preferences.
Helping us improve Convection
Convection is currently in version 1.1.4 and has been in production in two places (that we know of) for about five months as of June 8, 2010.
Please let us know if you are using Convection and enjoy it (or don't). Feel free to request features or alterations, but Convection is open source, so also please consider contributing if you have ideas!
March 23, 2010
Handy file: Capistrano recipe for cloning your remote (production) mysql database to your local box, and vice versa.
One of the more useful recipes for Capistrano that I've come across is this one by netzpirat. It's designed to give you simple commands that will dump the mysql database on your production server to a file, scp the file back to your development workstation, and import it into your local mysql database in a single operation. Very sexy, very powerful. (It also has commands for doing the reverse, and for uploading/downloading files as well - useful for things like paperclip attachments.)
Unfortunately, for my purposes, it failed right out of the box in two different ways:
It only pulls your database name, db login, and password from your database.yml, and then (on the remote server) tries to connect to the database on a local socket. Most of my databases are hosted on remote boxes.
It assumes your production database credentials exist in your local database.yml, which they really shouldn't in any decent deployment. Especially not if you are hosting your code in an outside repository.
Slightly improved version
So, I created this improved version, which pulls the remote DB credentials from the database.yml file on the remote server (assuming you've put them in #{shared_path}/config/database.yml), and which will also use the hostname, if any, from your database.yml when it executes mysqldump.
Let me know if you have any trouble with it and I'll correct any errors in the gist.
March 18, 2010
We've been working on an app that needs to stand astride two databases - one local DB for the app itself, and another with restrictive policies about modifications that is nonetheless authoritative on many subjects. There's a fair amount of tricky interaction between the two, and testing has been a delightful challenge.
We're using the use_db plugin, and all it takes to make testing transactions happen around multiple DBs is:
In: spec/spec_helper.rb
require 'override_test_callbacks'
My concern comes from the fact that this is a direct and unfiltered monkeypatch on ActiveRecord::TestFixtures. So it relies on use_transactional_fixtures (which could certainly be used without using actual fixures, granted), and if the test transaction code moves within Rails, that's another integration to worry about. Or if we add a spec that doesn't wind up making ActiveRecord::TestFixtures load... Or if we decide to use something other than use_db...
So instead I'm using:
Spec::Runner.configure do |config|
config.prepend_before do
(UseDbPlugin.all_use_dbs - [ActiveRecord::Base]).each do |db|
db.connection.increment_open_transactions
db.connection.transaction_joinable = false
db.connection.begin_db_transaction
end
end
config.append_after do
(UseDbPlugin.all_use_dbs - [ActiveRecord::Base]).reverse.each do |db|
db.connection.rollback_db_transaction
db.connection.decrement_open_transactions
end
end
end
If we weren't already using transactional fixtures, I might pull out the - [ActiveRecord::Base]. And if we were to change off of use_db, there's one place to change the transaction code. Finally, there's much less dependence on the innards of ActiveRecord - only it's published API.
March 10, 2010
Here's a little foible of ActiveRecord that cost me over an hour today. AR accepts both symbol keys and string keys when specifying attributes. Both of these are valid ways of mass assigning attributes to a Rails model:
MyModel.new(:field_1 => 'foo', :field_2 => 'bar')
MyModel.new('field_1' => 'foo', 'field_2' => 'bar')
It's convenient, often, to not have to worry about whether your keys are symbols are strings since they get converted around a bit when you pass parameters. The downside of this, however, is that it will happily accept BOTH without complaining, and will quietly default to the symbol key regardless of the order you specify them in:
>> model = MyModel.new(:field_1 => 'foo', 'field_1' => 'bar'); nil;
>> mymodel.field_1
=> 'foo'
>> model = MyModel.new('field_1' => 'foo', :field_1 => 'bar'); nil;
>> mymodel.field_1
=> 'bar'
Okay, so that's kinda sloppy. Bad ActiveRecord! No Biscuit!
This can cause serious confusion for the unwary. When ActionController hands us a params hash, it always has String keys, like this:
>> eval params
=> { 'article' => { 'title' => 'Awesome blog post', 'body' => 'I will make you smart' } }
But most of us, canonically, specify params and default AR values with symbols, like this:
post :article => {:title => 'Awesome blog post', :body => 'I will make you smart'}
So we get used to thinking about them as symbols.
This means we can make mistakes like this one I made recently. Consider this block of code for a shopping cart model that pre-fills some fields for an associated Payment by pulling the address from the user's profile, to save the user re-typing their address:
class ShoppingCart < ActiveRecord::Base
has_one :payment
def build_default_payment(options = {})
#prepopulate the billing address from the profile and merge
#with params passed into options
build_payment(prepopulated_fields.merge!(options)
end
def prepopulated_fields
if (addr = self.person.address)
{
:billing_address_1 => addr.line_1,
:billing_address_2 => addr.line_2,
:city => addr.city,
:state => addr.state,
:zip => addr.zipcode
}
else
{}
end
end
end
Looks great, right? And if the user's address has a nil field (like no city, or no line_1), it will get overwritten by the hash merge.
Except not. I specified symbol keys in prepopulated_fields, but the hash getting passed to build_default_payment's 'options' argument has string keys, because it's coming from params. So the merge doesn't overwrite the value for :line_1, it simply adds a new key 'line_1'. So, if a user has a profile address but hadn't entered a line_1 (just city and state), and then manually entered line_1 in the payment form to submit, the Payment build during the create action was getting this hash:
build_payment({
:line_1 => nil,
:city => 'Pasadena',
:state =>'CA',
:zipcode => '91106'
'line_1' => '100 Main St.'.
})
ActiveRecord was respecting the :line_1 => nil from the profile, and not the 'line_1' => '100 Main St.' from params. This meant that the user couldn't make payment! The payment had validates_inclusion_of line_1, and even though it was typed into the form it was getting ignored because of the nil from his profile address. Very frustrating for a user to manually type in a billing address and get back "Address Line 1 can't be blank." on every submit!
Nasty ... this one took a while to figure out. Beware of this little foible of ActiveRecord!
February 20, 2010
Continuous Integration is a key tool for collborative development, and CruiseControl.rb is the tool of choice for many Ruby and Rails teams, including us at Logical Reality.
Unfortunately, setting up CC.rb for a team can be a relatively frustrating experience: this guide (the first of a series of HOWTOs by LRD) will walk you through every step of setting up a team instance of CruiseControl.rb on a low-cost server from Slicehost.
Step 1: Lease a Ubuntu Slicehost account
I recommend a 384 slice or a 512 slice, as 256MB or RAM is pretty light for anything involving a Rails application. Our CI server runs on a 512 slice; if you are running it on a smaller slice please let us know how it performs.
I used Ubuntu 9.10 (Karmic) for this post.
Step 2: Create a working user
Slicehost configures slices with an active root account - definitely a Ubuntu no-no - and no user account. Ick! Let's start by creating a user account with sudo access to do everything from. Log in as root using the information Slicehost sends you, run this (replace 'usename' with whatever name you like) and fill in the information it asks for:
Then edit /etc/sudoers and add this line to the bottom of the file:
Log out, and log back in as the user you've now configured, to make sure it work.
Step 3: Installing packages and gems
Reset your timezone:
sudo dpkg-reconfigure tzdata
Install a whole bunch of packages you'll want for running Rails applications and hosting CruiseControl:
sudo aptitude install locate emacs git-core ruby build-essential \
libopenssl-ruby ruby1.8-dev irb apache2 apache2-mpm-prefork \
apache2-prefork-dev sqlite3 rubygems mysql-server mysql-client
Go grab a cup of coffee while those install. The mysql install will ask you to set a root password. Do so, and write it down for later use. When all the installs are done, come back and install the ruby gems you'll be needing:
sudo gem install sqlite3-ruby passenger mysql metric_fu reek roodi
Step 4: Assorted server configuration
Add this line to the bottom of your ~/.profile to put your gems in your path:
PATH="$PATH:/var/lib/gems/1.8/bin/"
And source it:
Some assorted config: set up the passenger module for Apache, set your hostname, and make /etc/hosts readable. (For some bizarre reason, /etc/hosts was only readable by root on my slice, and that has a tendency to break things down the road).
sudo /var/lib/gems/1.8/bin/passenger-install-apache2-module
sudo emacs /etc/hostname # set it to "your.hostname.com"
sudo /bin/hostname -F /etc/hostname
sudo chmod a+r /etc/hosts
Step 5: Configure Passenger and Apache
We'll run CruiseControl.rb with Apache and Passenger. Start by enabling the Passenger module. The command below will walk you through a super-easy configuration:
sudo /var/lib/gems/1.8/bin/passenger-install-apache2-module
When the command completes, it will give you three lines to paste into your apache config, they should look pretty much like these below. Put these lines at the top of /etc/apache2/apache2.conf. I included the hostname I set in the previous step as ServerName.
LoadModule passenger_module /var/lib/gems/1.8/gems/passenger-2.2.8/ext/apache2/mod_passenger.so
PassengerRoot /var/lib/gems/1.8/gems/passenger-2.2.8
PassengerRuby /usr/bin/ruby1.8
ServerName your.hostname.com
To set up the application itself, edit /etc/apache2/sites-available/default to look like this:
<VirtualHost *:80>
ServerAdmin administrator@your-email-domain.com
DocumentRoot /u/apps/cruisecontrol/public
RailsEnv production
RailsBaseURI /
ServerName <IP Address from Slicehost>
ServerAlias your.hostname.com
SetEnv PATH /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/var/lib/gems/1.8/bin/
ErrorLog /var/log/apache2/error.log
# Possible values include: debug, info, notice, warn, error, crit,
# alert, emerg.
LogLevel warn
CustomLog /var/log/apache2/access.log combined
</VirtualHost>
Make a home for the app. (I use /u/apps/ as a convention for apps in apache. Use whatever you like, but make sure your DocumentRoot in your config file above matches.)
Step 6: Download and install CruiseControl.rb
Download cruisecontrol.rb from RubyForge (Check for the current version first; it was 1.4.0 when I installed), and give ownership to the web user www-data:
cd /u/apps
sudo wget http://rubyforge.org/frs/download.php/59598/cruisecontrol-1.4.0.tgz
sudo tar -zxf cruisecontrol-1.4.0.tgz
sudo mv cruisecontrol-1.4.0 cruisecontrol
sudo chown -R :www-data cruisecontrol
Give environment.rb to the web user; this prevents an Errno::EACCES accessing environment.rb from Passenger (see discussion at this forum post).
sudo chown www-data:www-data config/environment.rb
Turn off the built-in htaccess, it will break Passenger:
sudo mv public/.htaccess public/.htaccess-disabled
cd config
sudo cp site_config.rb_example site_config.rb
Step 7: Setting up the user environment
CruiseControl.rb prefers, by default, to put project builds in the running user's ~/.cruise directory. This is unfortunate because the standard user for running Apache, www-data, doesn't have a user directory! There are ways to override this, but I've found that they cause significant problems down the line.
An example of the problem is letting CC.rb check out your source code. If you authenticate access to GitHub or another code repository with SSH, CC.rb — running as www-data — won't be able access your repo since www-data doesn't have a ~/.ssh directory to put the keys in!
After much hacking, I came to the unhappy conclusion that the best solution is simply to let CruiseControl.rb have its way and give user www-data a home directory. Boo, hiss, but here we go:
sudo /etc/init.d/apache2 stop
sudo usermod -d /home/www-data www-data
sudo usermod -s /bin/bash www-data
sudo /etc/init.d/apache2 start
If you give www-data standard config files as well, then you can set the PATH so that user www-data can find your gems, and you can set up ssh keys so that CruiseControl.rb can securely check out projects from GitHub or whatever source code repository you're using:
sudo cp -r /etc/skel /home/www-data
sudo chown www-data:www-data /home/www-data
sudo su www-data
cd
mkdir ~/.ssh
cd ~/.ssh
ssh-keygen -t rsa
cat id_rsa.pub
Add this line to the bottom of ~/www-data/.profile:
PATH="$PATH:/var/lib/gems/1.8/bin/"
Re-start Apache:
sudo /etc/init.d/apache2 restart
At this point, you should be able to load CruiseControl.rb in a web browser at the IP address given to you by Slicehost, or at the domain name if you've set up DNS and it's resolving. Congratulations, you have CC.rb up and running! One last thing to configure.
Running CruiseControl.rb will have created a configuration directory . ~www-data/.cruise. You'll want to edit ~www-data/.cruise/site_config.rb to set two options. Uncomment and set appropriate values for this line:
Configuration.email_from = 'cruisecontrolrb@mydomain.com'
Configuration.dashboard_url = 'http://my.cruisecontrolrb.host/'
Okay, it's time to get a project installed!
Step 8: Setting up your first project
I'll use Logical Reality's open-source project, Convection, as an example project for CruiseControl.rb. This works best if you run it as user 'www-data'.
The command for adding a new project is really simple:
cd /u/apps/cruisecontrol
sudo su www-data
./cruise add Convection -r git://github.com/LRDesign/Convection.git -s git
This will set up the build in ~www-data/.cruise/projects/Convection.
Create a test database for the application. For Convection, I'm going to use mysql, and prefix my database name with 'ci' for Continuous Integration.
mysqladmin -u root -p create ci_convection
We don't want to put a functioning database.yml in our GitHub repository, but at the same time we want CruiseControl.rb to be able to build and test the app without help from the user. For all our Rails projects, we use a custom rake task that generates a database.yml from command-line arguments, then rebuilds the database, run the specs, and generate output with metric_fu. For an example of how to do this, look at our integration.rake and ERB database.yml template from Convection.
To configure CruiseControl.rb to run Convection this, we need to add that task to the configuration file for this project. Edit ~www-data/.cruise/projects/Convection/cruise_config.rb so that it looks like this:
Project.configure do |project|
# Send email notifications about broken and fixed builds to email1@your.site, email2@your.site (default: send to nobody)
project.email_notifier.emails = ['sysadmin@lrdesign.com', 'judson@lrdesign.com']
# Set email 'from' field
project.email_notifier.from = 'sysadmin@lrdesign.com'
# Build the project by invoking rake task 'custom'
# project.rake_task = 'custom'
# Build the project by invoking shell script "build_my_app.sh". Keep in mind that when the script is invoked,
# current working directory is <em>[cruise data]</em>/projects/your_project/work, so if you do not keep build_my_app.sh
# in version control, it should be '../build_my_app.sh' instead
# project.build_command = 'build_my_app.sh'
project.build_command = 'rake ci:run[localhost,root,<YOUR_MYSQL_ROOT_PASSWORD>,ci_convection] --trace RAILS_ENV=test'
# Ping Subversion for new revisions every 5 minutes (default: 30 seconds)
# project.scheduler.polling_interval = 5.minutes
end
Step 9: There is no step nine!
Okay, so it's not the simplest thing in the world to set up. But if you've done everything above correctly, you should have a running server your team can use for continuous integration. If you've included metric_fu in your build task, you should get both test output and a wealth of useful code metrics.
Did this sequence work for you? Did I omit a step or misspell a command? Let me know in comments, and I'll update/correct the post.
February 19, 2010
We've finished our redesign project, and the first version of the new look is up in time for LA Rubyconf!
There's plenty more to do, but we're very happy to have a refreshed look. In can be hard, as a web development company with active clients, to find time to work on our own website!
December 14, 2009
The new Ruby on Rails Tutorial book and website by Michael Hartl has launched at RailsTutorial.org. Hartl is the author of RailsSpace and cofounder of the Insoshi Ruby on Rails social networking platform.
Logical Reality did the logo and layout design work for Rails Tutorial.
October 16, 2009
This blog, along with a dozen or so other CMS-driven sites I maintain, was compromised by a hacker recently. I've finally gotten this one back up and am working on the others.
August 9, 2009
AKA adventures in class loading.
A couple of days ago I did some significant work in authorization in one of my apps, involving creating a Groups class with an HABTM relationship to Person, so I could assign roles to people a group at a time. It all worked out great, and I pushed the product to GitHub. The next day, my collaborator wrote in that my recent contribution broke 119 specs.
I pulled and retested the code, and everything worked perfectly. WTF? After a bit of investigation, I discovered that the specs worked great when I ran 'autotest' or 'spec spec', but that 119 specs broke when I ran the exact same spec suite with 'rake spec'! Double WTF.
Setting constants at class loading
Ultimately, I tracked the problem down to this line and method, in Person.rb:
class Person << ActiveRecord::Base
ADMIN_GROUP = Group.find_by_name('Admin')
def admin?
groups.include? ADMIN_GROUP
end
end
I consider a person an administrator if they are a member of this group, and I was loading it as a constant at the class level in order to avoid having to query the database again every time Person#admin? is called. This worked just fine for me, both in the application, and every time I ran Person#admin?.
But, remarkably, ADMIN_GROUP does not get initialized correctly when I run the tests via rake. I found this via the ruby debugger, running in this particular spec in spec/models/person_spec.rb:
describe Person do
it "should load an admin user from fixture" do
debugger
people(:admin).should be_admin
end
end
When I run the specs and evaluate Person::ADMIN_USER, I get very different results depending on which spec runner I'm using:
Running 'spec spec/models/person_spec.rb':
[11:17:54 CITAlumni]$ spec spec/models/person_spec.rb
spec/models/person_spec.rb:64
people(:admin).should be_admin
(rdb:1) eval Person::ADMIN_GROUP
#
Running 'rake spec SPEC=spec/models/person_spec.rb':
[11:17:13 CITAlumni (48c51f1...)]$ rake spec SPEC=spec/models/person_spec.rb
(in /Users/evan/Development/Ruby/CITAlumni)
FF.....spec/models/person_spec.rb:64
people(:admin).should be_admin
(rdb:1) eval Person::ADMIN_GROUP
nil
How very interesting ... when I use rake, that constant initializes to nil. At some point, I'll actually get around to figuring out why this is so different when the specs are run via rake. In the meantime, the fix was easy:
The Solution
The fix was just to refactor ADMIN_GROUP as a class method with a memoized instance variable. This will at least limit DB queries for the admin group to one per page load; not quite as good as a single DB hit when the class is first loaded, but still a major improvement over querying for the admin group every time Person#admin? is called. I moved it to the Group class at the same time, which was probably the right place for it in the first place:
#app/models/group.rb:
class Group < ActiveRecord::Base
def self.admin_group
@admin_group ||= self.find_by_name('Admin')
end
end
#app/models/person.rb
class Group < ActiveRecord::Base
def admin?
groups.include? Group.admin_group
end
end
And this worked just fine in all environments, solving the problem with 'rake spec'.
The Moral
Be careful with depending on behavior that occurs only during the loading of classes, as it can be environment-dependent!
If anyone out there with uber Ruby skills knows exactly why running specs via rake prevents that class variable from loading correctly, please enlighten us in comments!