So, one of the things I wanted to add to Active Resource is the
ability to have knowledge of expected 'columns'. ie a set of things
that ARes knows are attributes of the given resource.
Why do I want this?
First let me explain the current situation.
Right now - ARes sets attributes based on whatever comes back from the
remote system when you do a fetch. This is fine and dandy and need not
be changed... but what if we're creating a brand new resource and
want to run local validations over it?
Say I'm implementing a flickr-image-submitting service. The flickr API
is known and published. Here's an example that shows up the problem:
class FlickrPhoto << ActiveResource::Base
validates_presence_of :photo
end
# this works
my_photo = FlickrPhoto.new(:photo => 'photo_stream_here', :title =>
'eben', :tags => ['cat'])
my_photo.valid? # => true
my_photo.save # => true
my_photo.photo # 'photo_stream_here'
# this doesn't
my_photo = FlickrPhoto.new(:title => 'snooch', :tags => ['cat']) #
note: no photo
my_photo.photo # throws NoMethodError exception
my_photo.valid? # throws NoMethodError exception
my_photo.save # throws NoMethodError exception
All the latter three don't work because we're calling 'photo' which
doesn't exist as a method on FlickrPhoto - even though we know in
advance (due to the published API) that photo is a required column.
This is pretty annoying when the validates_presence_of method should
be responding to exactly this situation...
We *could* overload validates_presence_of to respond to a
NoMethodException... but we'd probably also have to update errors.on
and the view-methods and various other locations... when all we really
want is to add something into method_missing to return 'nil' if we
know it's a column... but don't have a value yet.
Active Record uses a 'columns' method that is populated by reflecting
on the db table... clearly Active Resource doesn't have that luxury.
We could get all fancy and do a remote lookup on the remote system...
or we could just set the columns ourselves given that we know what
they should contain. eg:
class FlickrPhoto << ActiveResource::Base
columns = [:photo, :title, :description, :tags]
validates_presence_of :photo
end
# now this does...
my_photo = FlickrPhoto.new(:title => 'snooch', :tags => ['cat']) #
note: no photo
my_photo.photo # 'nil'
my_photo.valid? # returns 'false' with errors.on(:photo) == ['can't
be blank']
my_photo.save # returns 'false' with errors.on(:photo) == ['can't
be blank']
The latter seems like the simplest solution and AFAICS will not
interfere with how ARes currently runs.
It also has a nice side-effect of allowing plugins that assume the
existence of the 'columns' method to work with ARes as well. The
plugins shouldn't care that the columns come from the db-table or
otherwise...
I've provided a working proposed patch (below) to implement this and
would value opinion on a) the patch as is and b) any other options.
Note - this follows from discussion (and my original proposal) in the
following thread:
http://groups.google.com/group/rubyonrails-core/browse_thread/thread/2afb6dcb7c555eb1
Cheers,
Taryn
From 1cb4d7178f716d3c865ab8c36819c0f772fbfa01 Mon Sep 17 00:00:00 2001
From: taryn <
te...@globalpersonals.co.uk>
Date: Tue, 1 Sep 2009 11:51:38 +0100
Subject: [PATCH] Added "columns" to Active Resource to stop
NoMethodError
Basic proposed idea for "columns" for Active Resource. All it does is
make
certain that any known columns return 'nil' on access instead of
raising a
NoMethodError - when they aren't yet set by the remote system.
Columns can be set manually by a user by passing in an array of either
strings or symbols (they are converted to symbols internally).
This simply feeds method_missing - which will repsond with a nil
if an attribute has not been set but is a known column.
I've added base_tests for setting this attribute, and also
validations_tests
(because validates_presence_of requires an attribute to return 'nil'
instead
of raising an exception).
'columns' is also an expected interface feature for a lot of Active
Record
plugins. Adding this functionality to Active Resource means that it
will be
more easily interchangeable with AR.
At a later date we can consider funky things like remote lookup of
known
columns via a hit to the /new action - but for now - this is a
simple-but-solid foundation.
---
activeresource/lib/active_resource/base.rb | 51 +++++++++++++++++
+++++---
activeresource/test/cases/base_test.rb | 37 +++++++++++++++++
+
activeresource/test/cases/validations_test.rb | 29 +++++++++++++-
activeresource/test/fixtures/project.rb | 19 ++++-----
4 files changed, 118 insertions(+), 18 deletions(-)
diff --git a/activeresource/lib/active_resource/base.rb b/
activeresource/lib/active_resource/base.rb
index e5b8589..c14c16e 100644
--- a/activeresource/lib/active_resource/base.rb
+++ b/activeresource/lib/active_resource/base.rb
@@ -239,6 +239,40 @@ module ActiveResource
cattr_accessor :logger
class << self
+ # Accepts an array of column names that this Resource
recognises as
+ # known attributes.
+ #
+ # If the value of a known column has not been set and the value
is
+ # accessed - nil is returned instead of the usual +NoMethodError
+
+ # exception from +method_missing+
+ #
+ # This is especially important when you have a
+ # <tt>validates_presence_of</tt> validation on the column - as
this
+ # relies on the attribute returning 'nil' if it hasn't been set
and
+ # will not work if an exception is raised.
+ #
+ # example:
+ # me = Person.new(:name => 'me')
+ #
me.name # => 'me'
+ # me.age # => raises NoMethodError
+ #
+ # Person.columns = [:name, :age]
+ # you = Person.new(:name => 'you')
+ #
you.name # => 'you'
+ # you.age # => 'nil'
+ #
+ # All attributes returned by the remote system will still be
stored on
+ # the object regardless of what is in this column set.
+ def columns=(cols)
+ @columns = cols.blank? ? [] : cols.map(&:to_sym)
+ end
+ # Returns the current set of known columns/attributes of the
Resource
+ # as specified by <tt>columns=</tt>
+ def columns
+ defined?(@columns) ? @columns : []
+ end
+
+
# Gets the URI of the REST resources to map for this class.
The site variable is required for
# Active Resource's mapping to work.
def site
@@ -1207,12 +1241,17 @@ module ActiveResource
method_name = method_symbol.to_s
case method_name.last
- when "="
- attributes[method_name.first(-1)] = arguments.first
- when "?"
- attributes[method_name.first(-1)]
- else
- attributes.has_key?(method_name) ? attributes
[method_name] : super
+ when "="
+ attributes[method_name.first(-1)] = arguments.first
+ when "?"
+ attributes[method_name.first(-1)]
+ else
+ # if we have this attribute - return it
+ return attributes[method_name] if attributes.has_key?
(method_name)
+ # if this is a known column but hasn't been set - return
nil
+ return nil if self.class.columns.include?(method_symbol)
+ # otherwise defer upwards
+ super
end
end
end
diff --git a/activeresource/test/cases/base_test.rb b/activeresource/
test/cases/base_test.rb
index 8c0217a..50c6195 100644
--- a/activeresource/test/cases/base_test.rb
+++ b/activeresource/test/cases/base_test.rb
@@ -103,6 +103,43 @@ class BaseTest < Test::Unit::TestCase
end
+ def test_columns_accessor_accepts_array_of_syms
+ column_set = [:age, :name]
+
+ assert_nothing_raised { Person.columns = column_set }
+ assert_equal column_set, Person.columns
+ end
+
+ def test_columns_accessor_accepts_array_of_strings
+ column_set = ['name', 'age']
+ column_set_syms = column_set.map(&:to_sym)
+
+ assert_nothing_raised { Person.columns = column_set }
+ # should sort and sybolise them
+ assert_equal column_set_syms, Person.columns
+ end
+
+
+ def test_non_attribute_access_and_assignment_should_fail
+ me = Person.new
+ assert !me.respond_to?("my_unknown_column")
+ assert_raises(NoMethodError) { me.my_unknown_column }
+ end
+
+ def test_known_column_on_new_instance_should_return_nil
+ me = Person.new
+ assert !me.respond_to?("my_known_column") # sanity check
+
+ Person.columns = [:my_known_column]
+ assert_nothing_raised { assert_nil me.my_known_column }
+ # just for good measure - test we can set it too
+ assert_nothing_raised {
+ new_val = 'blah'
+ me.my_known_column = new_val
+ assert_equal new_val, me.my_known_column
+ }
+ end
+
def test_site_accessor_accepts_uri_or_string_argument
site = URI.parse('
http://localhost')
diff --git a/activeresource/test/cases/validations_test.rb b/
activeresource/test/cases/validations_test.rb
index a8ab7d6..255fc6c 100644
--- a/activeresource/test/cases/validations_test.rb
+++ b/activeresource/test/cases/validations_test.rb
@@ -5,7 +5,7 @@ require "fixtures/project"
# This test case simply makes sur that they are all accessible by
# Active Resource objects.
class ValidationsTest < ActiveModel::TestCase
- VALID_PROJECT_HASH = { :name => "My Project", :description => "A
project" }
+ VALID_PROJECT_HASH = { :name => "My Project", :owner =>
'Bob', :description => "A project", :remote_column => 'something' }
def setup
@my_proj = VALID_PROJECT_HASH.to_xml(:root => "person")
ActiveResource::HttpMock.respond_to do |mock|
@@ -13,7 +13,7 @@ class ValidationsTest < ActiveModel::TestCase
end
end
- def test_validates_presence_of
+ def test_validates_presence_of_known_column
p = new_project(:name => nil)
assert !p.valid?, "should not be a valid record without name"
assert !p.save, "should not have saved an invalid record"
@@ -23,6 +23,31 @@ class ValidationsTest < ActiveModel::TestCase
assert p.save, "should have saved after fixing the validation,
but had: #{p.errors.inspect}"
end
+
+ def test_validates_presence_of_accessor_backed_attribute
+ p = new_project(:owner => nil)
+ assert !p.valid?, "should not be a valid record without owner"
+ assert !p.save, "should not have saved an invalid record"
+ assert_equal ["can't be blank"], p.errors[:owner], "should have
an error on name"
+
+ p.owner = "somebody"
+
+ assert p.save, "should have saved after fixing the validation,
but had: #{p.errors.inspect}"
+ end
+
+ def
test_validates_presence_of_unknown_column_should_raise_exception_if_not_present
+ p = Project.new(VALID_PROJECT_HASH.delete_if{|k,v| k
== :remote_column})
+ assert_raises(NoMethodError) {
+ assert !p.valid?, "should not be a valid record without
remote_column"
+ }
+ p.remote_column = "something"
+ assert_nothing_raised {
+ assert p.valid?, "should be a valid record with the remote
column set"
+ assert p.save, "should have saved after fixing the validation,
but had: #{p.errors.inspect}"
+ }
+
+ end
+
def test_fails_save!
p = new_project(:name => nil)
diff --git a/activeresource/test/fixtures/project.rb b/activeresource/
test/fixtures/project.rb
index e15fa6f..e800d39 100644
--- a/activeresource/test/fixtures/project.rb
+++ b/activeresource/test/fixtures/project.rb
@@ -1,8 +1,9 @@
# used to test validations
class Project < ActiveResource::Base
self.site = "
http://37s.sunrise.i:3000"
+ self.columns = [:name, :description]
- validates_presence_of :name
+ validates_presence_of :name, :owner, :remote_column
validate :description_greater_than_three_letters
# to test the validate *callback* works
@@ -11,15 +12,13 @@ class Project < ActiveResource::Base
end
- # stop-gap accessor to default this attribute to nil
- # Otherwise the validations fail saying that the method does not
exist.
- # In future, method_missing will be updated to not explode on a
known
- # attribute.
- def name
- attributes['name'] || nil
- end
- def description
- attributes['description'] || nil
+ # This attribute isn't in columns - so we need to fake up this nil-
ifying
+ # accessor function so that validates_presence_of does not explode
when
+ # the attribute hasn't yet been set. Compare with the :name column
+ def owner
+ # this is clearly just a test-function, but you could, say, use
this to
+ # do a db lookup on the User table for the owner
+ attributes['owner'] || nil
end
end
--
1.6.0.4