How has Ruby blown my mind?
By the way, if you don't know about Pat Eyler's & Apress' ongoing blog about Ruby contests, check out this month's contest.
Pat has convinced me to set some minor goals on the way to completing Structure and Interpretation of Computer Programs together. The first goal is to get the development environment installed. We have agreed to complete it by Monday, September 11th.
foo.bar.baz
) The reason they are bad is because they generally represent a place where encapsulation is broken. In my example, the currently running code knows that foo
provides bar
, which is ok, but it also knows that bar
provides baz
. Really, the currently running code should only know the methods on foo
. If it needs baz
, it should call foo.baz
. Note that this is not the case with Java's sb = new StringBuffer(); sb.append(str).append(str);
since in this example, you are working on sb
each time and not descending down into sb
's children.class User < ActiveRecord::Base; end
class Agent < User
has_one :agent_detail
end
class AgentDetail < ActiveRecord::Base
belongs_to :agent
belongs_to :address
end
class Address < ActiveRecord::Base
has_one :agent_detail
end
users: id, type, *
agent_details: id, agent_id, address_id, *
address: id, *
bob
. To get bob's zip code, you would have to do bob.agent_detail.address.zip_code
, breaking encapsulation and delving far too deep into implementation details of an agent. What we really want to be able to do is bob.zip_code
. Our code shouldn't care how bob implements this field but it should be able to get this information directly from bob. We also need to be able to set this information on bob with bob.zip_code = '12345'
. The normal and naive way to do this follows:class Agent < User
has_one :agent_detail
def zip_code
agent_detail.zip_code
end
def zip_code=(zip_code)
agent_detail.zip_code = zip_code
end
end
class AgentDetail < ActiveRecord::Base
belongs_to :agent
belongs_to :address
def zip_code
address.zip_code
end
def zip_code=(zip_code)
address.zip_code = zip_code
end
end
bob.zip_code
but it has some problems. For one, at the moment, nothing is ensuring all agents have an agent_detail and all agent_details have an address. This means we are subject to nil problems (also know as NullPointerExceptions in the Java world). Also, say we wanted to change the name of zip_code to zip? We would have to change a table and a total of 8 lines of code in 2 different classes. Not exactly the most robust system. This also has some problems with saving dependent objects. If you do bob.zip = '12345'; bob.save
, odds are the you expect the zip code change on address to be saved but it isn't.bob.zip_code
method and delegate it down and before_save
and after_safe
ActiveRecord callbacks help solve the save problems. This makes your code look like this:class Agent < User
has_one :agent_detail
after_save {|agent| agent.agent_detail.save}
def safe_agent_detail
agent_detail.nil? : build_agent_detail ? agent_detail
end
def method_missing(method_id, *args)
if safe_agent_detail.respond_to? method_id
agent_detail.send(method_id, *args)
else
super
end
end
def respond_to?(method_id)
safe_agent_detail.respond_to?(method_id) || super
end
end
class AgentDetail < ActiveRecord::Base
belongs_to :agent
belongs_to :address
before_save {|agent_detail| agent_detail.address.save}
def safe_address
address.nil? : build_address ? address
end
def method_missing(method_id, *args)
if safe_address.respond_to? method_id
address.send(method_id, *args)
else
super
end
end
def respond_to?(method_id)
safe_address.respond_to?(method_id) || super
end
end
class User < ActiveRecord::Base; end
class Agent < User
has_one_delegate :agent_detail
end
class AgentDetail < ActiveRecord::Base
belongs_to :agent
belongs_to_delegate :address
end
class Address < ActiveRecord::Base
has_one :agent_detail
end
has_one_delegate
and belongs_to_delegate
:module ActiveRecord
class Base
def self.belongs_to_delegate(delegate_id, options = {})
delegate_to(:belongs_to, delegate_id, options)
end
def self.has_one_delegate(delegate_id, options = {})
delegate_to(:has_one, delegate_id, options)
end
private
def self.delegate_to(macro, delegate_id, options = {})
send macro, delegate_id, options
save_callback = {:belongs_to => :before_save, :has_one => :after_save}[macro]
send save_callback do |model|
model.send(delegate_id).save
end
delegate_names = respond_to?('safe_delegate_names') ? safe_delegate_names : []
delegate_names = (delegate_names - [delegate_id]) + [delegate_id]
def_string, source_file, source_line = <<-"end_eval", __FILE__, __LINE__
def self.safe_delegate_names
#{delegate_names.inspect}
end
def safe_delegates
self.class.safe_delegate_names.collect do |delegate_name|
send(delegate_name).nil? ? send("build_\#{delegate_name}") : send(delegate_name)
end
end
def method_missing(method_id, *args)
safe_delegates.each do |delegate|
return delegate.send(method_id, *args) if delegate.respond_to?(method_id)
end
super
end
def respond_to?(*args)
safe_delegates.any? {|delegate| delegate.respond_to?(*args)} || super
end
end_eval
module_eval def_string, source_file, source_line + 1
end
end
end
require File.dirname(__FILE__) + '/../test_helper'
class ATest < Test::Unit::TestCase
fixtures :empties
def setup
end
def test_fixture
empties(:first)
end
end
require File.dirname(__FILE__) + '/a_test'
class BTest < ATest; end
[~/projects/test]$ /usr/bin/ruby -Ilib:test "/usr/lib/ruby/gems/1.8/gems/rake-0.7.1/lib/rake/rake_test_loader.rb" "test/unit/a_test.rb" Loaded suite /usr/lib/ruby/gems/1.8/gems/rake-0.7.1/lib/rake/rake_test_loader
Started
.
Finished in 0.115591 seconds.
1 tests, 0 assertions, 0 failures, 0 errors
[~/projects/test]$ /usr/bin/ruby -Ilib:test "/usr/lib/ruby/gems/1.8/gems/rake-0.7.1/lib/rake/rake_test_loader.rb" "test/unit/b_test.rb" Loaded suite /usr/lib/ruby/gems/1.8/gems/rake-0.7.1/lib/rake/rake_test_loader
Started
..
Finished in 0.157902 seconds.
2 tests, 0 assertions, 0 failures, 0 errors
[~/projects/test]$ /usr/bin/ruby -Ilib:test "/usr/lib/ruby/gems/1.8/gems/rake-0.7.1/lib/rake/rake_test_loader.rb" "test/unit/a_test.rb" "test/unit/b_test.rb"
Loaded suite /usr/lib/ruby/gems/1.8/gems/rake-0.7.1/lib/rake/rake_test_loader
Started
EEEE
Finished in 0.088633 seconds.
1) Error:
test_fixture(ATest):
NoMethodError: You have a nil object when you didn't expect it!
You might have expected an instance of Array.
The error occured while evaluating nil.[]
/usr/lib/ruby/gems/1.8/gems/activerecord-1.14.2/lib/active_record/fixtures.rb:475:in `empties'
./test/unit/a_test.rb:10:in `test_fixture'
2) Error:
test_fixture(ATest):
NoMethodError: You have a nil object when you didn't expect it!
You might have expected an instance of Array.
The error occured while evaluating nil.-
/usr/lib/ruby/gems/1.8/gems/activerecord-1.14.2/lib/active_record/transactions.rb:112:in `unlock_mutex'
/usr/lib/ruby/gems/1.8/gems/activerecord-1.14.2/lib/active_record/fixtures.rb:534:in `teardown'
3) Error:
test_fixture(BTest):
NoMethodError: You have a nil object when you didn't expect it!
You might have expected an instance of Array.
The error occured while evaluating nil.[]
/usr/lib/ruby/gems/1.8/gems/activerecord-1.14.2/lib/active_record/fixtures.rb:475:in `empties'
./test/unit/a_test.rb:10:in `test_fixture'
4) Error:
test_fixture(BTest):
NoMethodError: You have a nil object when you didn't expect it!
You might have expected an instance of Array.
The error occured while evaluating nil.-
/usr/lib/ruby/gems/1.8/gems/activerecord-1.14.2/lib/active_record/transactions.rb:112:in `unlock_mutex'
/usr/lib/ruby/gems/1.8/gems/activerecord-1.14.2/lib/active_record/fixtures.rb:534:in `teardown'
2 tests, 0 assertions, 0 failures, 4 errors
[~/projects/test]$
test_helper
in Rails tests.diff
. Trust diff
.require File.dirname(__FILE__) + '/../test_helper'
. If you remove it, or you write your own test and forget it, bad things can happen, things like this....rake test_functional
. Failure. Hmm. I expected that. This project was written before we fully committed to test first. It shouldn't be too hard to get the existing tests to run. Let's see what we need.test/mock
directory from one rails app to the other.)rake test_functional
. Blamo. Still failing. Hmm. Naming problems? File names seem to match. Hack. Hack. Hack. This sucks, I've burned an afternoon and there's nothing to show for it.puts $:.inspect
["lib", "test", "/usr/lib/site_ruby/1.8", "/usr/lib/site_ruby/1.8/i386-linux",
"/usr/lib/site_ruby", "/usr/lib/ruby/1.8", "/usr/lib/ruby/1.8/i386-linux",
"."]
rake test_functional
Passed. Everything passed. Wait. WTF do you mean everything passed?! OK. Maybe I'm just an idiot (I mean past experience suggest this must frequently be the case.) so lets make sure I have the current code. svn status
. Nothing modified. svn udpate
. Nothing missing. Well, maybe Steve made changes on his machine. svn status
. Nothing modified. svn udpate
. Nothing missing.http
and I was looking for the svn+ssh
location..tar.bz2
but the file name in the directory is .tar.gz
. Don't ask how long it took. Too long.diff
the output. I should have done this days ago but hindsight is 20/20 and foresight is blind as a bat. Well, the commands for the tests aren't in the same order but I don't really see anything else. Lets follow the rake
trace.autotest
but not rake
. Now that is just creepy.test_helper
. And guess what. test_helper
is where environment.rb
is loaded. And environment.rb
is where the additional include paths are added, not to mention that test_helper
is where the mock paths are added. No wonder it couldn't find those mocks that I added.test_helper
, or even better a way to avoid that step in every single bloody test file. We will also be more careful to trap only what we expect and leave the unexpected unhandled or at least properly handled. And I will definitely learn to trust my diff
s earlier.{ |x, y, z| puts x, y, z }
uses three local variables. Interestingly, these three variables could each potentially have a different scope.x
in the current local scope, the variable x
used in the block will be that local variable, meaning the assignment will be an :lasgn
(local assignment).y
in the current local scope but there is a y
in an enclosing block, the y
variable used will be the block scoped variable from the outer block and the assignment will be a :dasgn
(block assignment).z
in the current local scope and there is no z
in any enclosing blocks, then a new z
will be created in the scope of the current block and the assignment will be a :dasgn_curr
(block local assignment).$ echo "x = 123; foo() {|x| y = 456; bar() {|x,y,z| puts x, y, z}}" | parse_tree_show -f
[[:lasgn, :x, [:lit, 123]],
[:iter,
[:fcall, :foo],
[:lasgn, :x],
[:block,
[:dasgn_curr, :y],
[:dasgn_curr, :y, [:lit, 456]],
[:iter,
[:fcall, :bar],
[:masgn, [:array, [:lasgn, :x], [:dasgn, :y], [:dasgn_curr, :z]]],
[:fcall, :puts, [:array, [:lvar, :x], [:dvar, :y], [:dvar, :z]]]]]]]
x = 123
foo() { |x|
y = 456
bar() { |x,y,z|
puts x, y, z
}
}
[:masgn, [:array, [:lasgn, :x], [:dasgn, :y], [:dasgn_curr, :z]]]
. The first thing to notice is:masgn
. :masgn
stands for multiple assignment and will be covered in its own post. After the :masgn
you can see concrete examples of the local assignment to x
, the outer block assignment to y
and the current block assignment to z
.Grandchild < Child < Parent < ActiveRecord::Base
, your controllers need model :grandchild
. Without it, well, this could happen:Admin < Broker < User < ActiveRecord::Base
all in table :users. Errors started showing up in development that passed in test. We tracked it down to differing sql statements. In test, Broker.find_all
generated SELECT * FROM users WHERE ( (users.`type` = 'Broker' OR users.`type` = 'Admin' ) )
. In development, the exact same Broker.find_all
generated SELECT * FROM users WHERE ( (users.`type` = 'Broker' ) )
.Default < ActiveRecord::Base; belongs_to Broker
. If the default.broker_id
pointed to a user
with type=Admin
, it failed in development. Default.broker
was returning nil when it should have been returning an Admin
. Again, the SQL was different between test and development in exactly the same way. Development would only accept Brokers
and test accepted Brokers
and Admins
. This didn't make any sense and this time, we had time to look at it.export RAILS_ENV=test; ./script/console
. So far, so good. Broker.find_all;
I only get Brokers
back. WTF! That's the same thing I've been seeing in development. A quick check. Yes, I'm in test. Hmm. User.find_all;
I get everything. (select * from users
, no where clause) Admin.find_all;
I get only Admins
. I expected that, Admin
doesn't have any children. Broker.find_all;
Holy hand grenades. That is everything, Admins
included. The where clause changed. Umm. But. Maybe this time I put something different. Just to be sure, I used up arrow to run the first Broker.find_all;
(I couldn't see a difference but who knows at this point.) Sure enough, the original search turns up the new results. Then, wham, lightning struck. If Admin
has never been loaded, Rails doesn't know Admin < Broker
!model :admin
to the controller) and sure enough, everything is peachy. There is just one nagging doubt. I don't know how to create this in test. You see, single table inheritance is implemented in a single YAML fixture. When fixtures :users
runs, Rails figures out I have a User
, Broker
, Customer
(< User
) and Admin
model. Loading my fixture seems to make Rails aware of those relationships. So, how do I get data for admins into the table for users without making Rails aware of the Admin
class? I have no idea.