From fc9b3e4a45a81b7526f8154049c825e3755903ad Mon Sep 17 00:00:00 2001 From: Taryn East Date: Sun, 20 Dec 2009 18:42:16 -0600 Subject: define_schema for Active Resource Signed-off-by: Joshua Peek --- activeresource/lib/active_resource/base.rb | 144 +++++++- .../lib/active_resource/schema_definition.rb | 58 +++ activeresource/test/cases/base/schema_test.rb | 409 +++++++++++++++++++++ 3 files changed, 609 insertions(+), 2 deletions(-) create mode 100644 activeresource/lib/active_resource/schema_definition.rb create mode 100644 activeresource/test/cases/base/schema_test.rb diff --git a/activeresource/lib/active_resource/base.rb b/activeresource/lib/active_resource/base.rb index 18105e8887..60bd573911 100644 --- a/activeresource/lib/active_resource/base.rb +++ b/activeresource/lib/active_resource/base.rb @@ -13,6 +13,7 @@ require 'set' require 'uri' require 'active_resource/exceptions' +require 'active_resource/schema_definition' module ActiveResource # ActiveResource::Base is the main class for mapping RESTful resources as models in a Rails application. @@ -241,6 +242,127 @@ module ActiveResource cattr_accessor :logger class << self + # This will shortly disappear to be replaced by the migration-style + # usage of this for defining a schema. At that point, all the doc + # currenlty on schema= will move back here... + def schema # :nodoc: + @schema ||= nil + end + # Creates a schema for this resource - setting the attributes that are + # known prior to fetching an instance from the remote system. + # + # The schema helps define the set of known_attributes of the + # current resource. + # + # There is no need to specify a schema for your Active Resource. If + # you do not, the known_attributes will be guessed from the + # instance attributes returned when an instance is fetched from the + # remote system. + # + # example: + # class Person < ActiveResource::Base + # define_schema do |s| + # # define each attribute separately + # s.attribute 'name', :string + # + # # or use the convenience methods and pass >=1 attribute names + # s.string 'eye_colour', 'hair_colour' + # s.integer 'age' + # s.float 'height', 'weight' + # + # # unsupported types should be left as strings + # # overload the accessor methods if you need to convert them + # s.attribute 'created_at', 'string' + # end + # end + # + # p = Person.new + # p.respond_to? :name # => true + # p.respond_to? :age # => true + # p.name # => nil + # p.age # => nil + # + # j = Person.find_by_name('John') # John343 + # j.respond_to? :name # => true + # j.respond_to? :age # => true + # j.name # => 'John' + # j.age # => '34' # note this is a string! + # j.num_children # => '3' # note this is a string! + # + # p.num_children # => NoMethodError + # + # Attribute-types must be one of: + # string, integer, float + # + # Note: at present the attribute-type doesn't do anything, but stay + # tuned... + # Shortly it will also *cast* the value of the returned attribute. + # ie: + # j.age # => 34 # cast to an integer + # j.weight # => '65' # still a string! + # + def define_schema + schema_definition = SchemaDefinition.new + yield schema_definition if block_given? + + # skip out if we didn't define anything + return unless schema_definition.attrs.present? + + @schema ||= {}.with_indifferent_access + @known_attributes ||= [] + + schema_definition.attrs.each do |k,v| + @schema[k] = v + @known_attributes << k + end + + schema + end + + + # Alternative, direct way to specify a schema for this + # Resource. define_schema is more flexible, but this is quick + # for a very simple schema. + # + # Pass the schema as a hash with the keys being the attribute-names + # and the value being one of the accepted attribute types (as defined + # in define_schema) + # + # example: + # + # class Person < ActiveResource::Base + # schema = {'name' => :string, 'age' => :integer } + # end + # + # The keys/values can be strings or symbols. They will be converted to + # strings. + # + def schema=(the_schema) + unless the_schema.present? + # purposefully nulling out the schema + @schema = nil + @known_attributes = [] + return + end + + raise ArgumentError, "Expected a hash" unless the_schema.kind_of? Hash + + define_schema do |s| + the_schema.each {|k,v| s.attribute(k,v) } + end + end + + # Returns the list of known attributes for this resource, gathered + # from the provided schema + # Attributes that are known will cause your resource to return 'true' + # when respond_to? is called on them. A known attribute will + # return nil if not set (rather than MethodNotFound); thus + # known attributes can be used with validates_presence_of + # without a getter-method. + def known_attributes + @known_attributes ||= [] + 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 @@ -776,6 +898,21 @@ module ActiveResource attr_accessor :attributes #:nodoc: attr_accessor :prefix_options #:nodoc: + # If no schema has been defined for the class (see + # ActiveResource::schema=), the default automatic schema is + # generated from the current instance's attributes + def schema + self.class.schema || self.attributes + end + + # This is a list of known attributes for this resource. Either + # gathered fromthe provided schema, or from the attributes + # set on this instance after it has been fetched from the remote system. + def known_attributes + self.class.known_attributes + self.attributes.keys.map(&:to_s) + end + + # Constructor method for \new resources; the optional +attributes+ parameter takes a \hash # of attributes for the \new resource. # @@ -1157,7 +1294,7 @@ module ActiveResource method_name = method.to_s if attributes.nil? super - elsif attributes.has_key?(method_name) + elsif known_attributes.include?(method_name) true elsif method_name =~ /(?:=|\?)$/ && attributes.include?($`) true @@ -1262,7 +1399,10 @@ module ActiveResource attributes[$`] end else - attributes.include?(method_name) ? attributes[method_name] : super + return attributes[method_name] if attributes.include?(method_name) + # not set right now but we know about it + return nil if known_attributes.include?(method_name) + super end end end diff --git a/activeresource/lib/active_resource/schema_definition.rb b/activeresource/lib/active_resource/schema_definition.rb new file mode 100644 index 0000000000..abcbf3ba4e --- /dev/null +++ b/activeresource/lib/active_resource/schema_definition.rb @@ -0,0 +1,58 @@ +require 'active_resource/exceptions' + +module ActiveResource # :nodoc: + class SchemaDefinition # :nodoc: + + # attributes can be known to be one of these types. They are easy to + # cast to/from. + KNOWN_ATTRIBUTE_TYPES = %w( string integer float ) + + # An array of attribute definitions, representing the attributes that + # have been defined. + attr_accessor :attrs + + # The internals of an Active Resource SchemaDefinition are very simple - + # unlike an Active Record TableDefinition (on which it is based). + # It provides a set of convenience methods for people to define their + # schema using the syntax: + # define_schema do |s| + # s.string :foo + # s.integer :bar + # end + # + # The schema stores the name and type of each attribute. That is then + # read out by the define_schema method to populate the actual + # Resource's schema + def initialize + @attrs = {} + end + + def attribute(name, type, options = {}) + raise ArgumentError, "Unknown Attribute type: #{type.inspect} for key: #{name.inspect}" unless type.nil? || SchemaDefinition::KNOWN_ATTRIBUTE_TYPES.include?(type.to_s) + + the_type = type.to_s + # TODO: add defaults + #the_attr = [type.to_s] + #the_attr << options[:default] if options.has_key? :default + @attrs[name.to_s] = the_type + self + end + + # The following are the attribute types supported by Active Resource + # migrations. + # TODO: We should eventually support all of these: + # %w( string text integer float decimal datetime timestamp time date binary boolean ).each do |attr_type| + KNOWN_ATTRIBUTE_TYPES.each do |attr_type| + class_eval <<-EOV + def #{attr_type.to_s}(*args) # def string(*args) + options = args.extract_options! # options = args.extract_options! + attr_names = args # attr_names = args + # + attr_names.each { |name| attribute(name, '#{attr_type}', options) } # attr_names.each { |name| attribute(name, 'string', options) } + end # end + EOV + + end + + end +end diff --git a/activeresource/test/cases/base/schema_test.rb b/activeresource/test/cases/base/schema_test.rb new file mode 100644 index 0000000000..398e7cf539 --- /dev/null +++ b/activeresource/test/cases/base/schema_test.rb @@ -0,0 +1,409 @@ +require 'abstract_unit' +require "fixtures/person" +require "fixtures/street_address" + +######################################################################## +# Testing the schema of your Active Resource models +######################################################################## +class SchemaTest < ActiveModel::TestCase + def setup + @matz = { :id => 1, :name => 'Matz' }.to_xml(:root => 'person') + @david = { :id => 2, :name => 'David' }.to_xml(:root => 'person') + @greg = { :id => 3, :name => 'Greg' }.to_xml(:root => 'person') + @addy = { :id => 1, :street => '12345 Street', :country => 'Australia' }.to_xml(:root => 'address') + @default_request_headers = { 'Content-Type' => 'application/xml' } + @rick = { :name => "Rick", :age => 25 }.to_xml(:root => "person") + @people = [{ :id => 1, :name => 'Matz' }, { :id => 2, :name => 'David' }].to_xml(:root => 'people') + @people_david = [{ :id => 2, :name => 'David' }].to_xml(:root => 'people') + @addresses = [{ :id => 1, :street => '12345 Street', :country => 'Australia' }].to_xml(:root => 'addresses') + + ActiveResource::HttpMock.respond_to do |mock| + mock.get "/people/1.xml", {}, @matz + mock.get "/people/2.xml", {}, @david + mock.get "/people/Greg.xml", {}, @greg + mock.get "/people/4.xml", {'key' => 'value'}, nil, 404 + mock.get "/people/5.xml", {}, @rick + mock.put "/people/1.xml", {}, nil, 204 + mock.delete "/people/1.xml", {}, nil, 200 + mock.delete "/people/2.xml", {}, nil, 400 + mock.get "/people/99.xml", {}, nil, 404 + mock.post "/people.xml", {}, @rick, 201, 'Location' => '/people/5.xml' + mock.get "/people.xml", {}, @people + mock.get "/people/1/addresses.xml", {}, @addresses + mock.get "/people/1/addresses/1.xml", {}, @addy + mock.get "/people/1/addresses/2.xml", {}, nil, 404 + mock.get "/people/2/addresses/1.xml", {}, nil, 404 + mock.get "/people/Greg/addresses/1.xml", {}, @addy + mock.put "/people/1/addresses/1.xml", {}, nil, 204 + mock.delete "/people/1/addresses/1.xml", {}, nil, 200 + mock.post "/people/1/addresses.xml", {}, nil, 201, 'Location' => '/people/1/addresses/5' + mock.get "/people//addresses.xml", {}, nil, 404 + mock.get "/people//addresses/1.xml", {}, nil, 404 + mock.put "/people//addressaddresseses/1.xml", {}, nil, 404 + mock.delete "/people//addresses/1.xml", {}, nil, 404 + mock.post "/people//addresses.xml", {}, nil, 404 + mock.head "/people/1.xml", {}, nil, 200 + mock.head "/people/Greg.xml", {}, nil, 200 + mock.head "/people/99.xml", {}, nil, 404 + mock.head "/people/1/addresses/1.xml", {}, nil, 200 + mock.head "/people/1/addresses/2.xml", {}, nil, 404 + mock.head "/people/2/addresses/1.xml", {}, nil, 404 + mock.head "/people/Greg/addresses/1.xml", {}, nil, 200 + end + + Person.user = nil + Person.password = nil + end + def teardown + Person.schema = nil # hack to stop test bleedthrough... + end + + + ##################################################### + # Passing in a schema directly and returning it + #### + + test "schema on a new model should be empty" do + assert Person.schema.blank?, "should have a blank class schema" + assert Person.new.schema.blank?, "should have a blank instance schema" + end + + test "schema should only accept a hash" do + ["blahblah", ['one','two'], [:age, :name], Person.new].each do |bad_schema| + assert_raises(ArgumentError,"should only accept a hash (or nil), but accepted: #{bad_schema.inspect}") do + Person.schema = bad_schema + end + end + end + + test "schema should accept a simple hash" do + new_schema = {'age' => 'integer', 'name' => 'string'} + + assert_nothing_raised { Person.schema = new_schema } + assert_equal new_schema, Person.schema + end + + test "schema should accept a hash with simple values" do + new_schema = {'age' => 'integer', 'name' => 'string', 'height' => 'float', 'mydatetime' => 'string'} + + assert_nothing_raised { Person.schema = new_schema } + assert_equal new_schema, Person.schema + end + + test "schema should accept all known attribute types as values" do + # I'd prefer to use here... + ActiveResource::SchemaDefinition::KNOWN_ATTRIBUTE_TYPES.each do |the_type| + assert_nothing_raised("should have accepted #{the_type.inspect}"){ Person.schema = {'my_key' => the_type }} + end + end + + test "schema should not accept unknown values" do + bad_values = [ :oogle, :blob, 'thing'] + + bad_values.each do |bad_value| + assert_raises(ArgumentError,"should only accept a known attribute type, but accepted: #{bad_value.inspect}") do + Person.schema = {'key' => bad_value} + end + end + end + + test "schema should accept nil and remove the schema" do + new_schema = {'age' => 'integer', 'name' => 'string'} + assert_nothing_raised { Person.schema = new_schema } + assert_equal new_schema, Person.schema # sanity check + + + assert_nothing_raised { Person.schema = nil } + assert_nil Person.schema, "should have nulled out the schema, but still had: #{Person.schema.inspect}" + end + + + test "schema should be with indifferent access" do + new_schema = {:age => :integer, 'name' => 'string'} + new_schema_syms = new_schema.keys + + assert_nothing_raised { Person.schema = new_schema } + new_schema_syms.each do |col| + assert Person.new.respond_to?(col.to_s), "should respond to the schema's string key, but failed on: #{col.to_s}" + assert Person.new.respond_to?(col.to_sym), "should respond to the schema's symbol key, but failed on: #{col.to_sym}" + end + end + + + test "schema on a fetched resource should return all the attributes of that model instance" do + p = Person.find(1) + s = p.schema + + assert s.present?, "should have found a non-empty schema!" + + p.attributes.each do |the_attr, val| + assert s.has_key?(the_attr), "should have found attr: #{the_attr} in schema, but only had: #{s.inspect}" + end + end + + test "with two instances, default schema should match the attributes of the individual instances - even if they differ" do + matz = Person.find(1) + rick = Person.find(5) + + m_attrs = matz.attributes.keys.sort + r_attrs = rick.attributes.keys.sort + + assert_not_equal m_attrs, r_attrs, "should have different attributes on each model" + + assert_not_equal matz.schema, rick.schema, "should have had different schemas too" + end + + test "defining a schema should return it when asked" do + assert Person.schema.blank?, "should have a blank class schema" + new_schema = {'name' => 'string', 'age' => 'integer', 'height' => 'float', 'weight' => 'float'} + assert_nothing_raised { + Person.schema = new_schema + assert_equal new_schema, Person.schema, "should have saved the schema on the class" + assert_equal new_schema, Person.new.schema, "should have mde the schema available to every instance" + } + end + + test "defining a schema, then fetching a model should still match the defined schema" do + # sanity checks + assert Person.schema.blank?, "should have a blank class schema" + new_schema = {'name' => 'string', 'age' => 'integer', 'height' => 'float', 'weight' => 'float'} + + matz = Person.find(1) + assert !matz.schema.blank?, "should have some sort of schema on an instance variable" + assert_not_equal new_schema, matz.schema, "should not have the class-level schema until it's been added to the class!" + + assert_nothing_raised { + Person.schema = new_schema + assert_equal new_schema, matz.schema, "class-level schema should override instance-level schema" + } + end + + + ##################################################### + # Using the define_schema syntax + #### + + test "should be able to use define_schema" do + assert Person.respond_to?(:define_schema), "should at least respond to the define_schema method" + + assert_nothing_raised("Should allow the define_schema to take a block") do + Person.define_schema do |s| + assert s.kind_of?(ActiveResource::SchemaDefinition), "the 's' should be a schema definition or we're way off track..." + end + end + end + + test "schema definition should store and return attribute set" do + assert_nothing_raised do + Person.define_schema do |s| + assert s.respond_to?(:attrs), "should return attributes in theory" + s.attribute :foo, :string + assert_equal({'foo' => 'string' }, s.attrs, "should return attributes in practice") + end + end + end + + test "should be able to add attributes through define_schema" do + assert_nothing_raised do + Person.define_schema do |s| + assert s.attribute('foo', 'string'), "should take a simple attribute" + assert s.attrs.has_key?('foo'), "should have saved the attribute name" + assert_equal 'string', s.attrs['foo'], "should have saved the attribute type" + end + end + end + + test "should convert symbol attributes to strings" do + assert_nothing_raised do + Person.define_schema do |s| + assert s.attribute(:foo, :integer), "should take a simple attribute as symbols" + assert s.attrs.has_key?('foo'), "should have saved the attribute name as a string" + assert_equal 'integer', s.attrs['foo'], "should have saved the attribute type as a string" + end + end + end + + test "should be able to add all known attribute types" do + Person.define_schema do |s| + ActiveResource::SchemaDefinition::KNOWN_ATTRIBUTE_TYPES.each do |the_type| + assert_nothing_raised do + assert s.attribute('foo', the_type), "should take a simple attribute of type: #{the_type}" + assert s.attrs.has_key?('foo'), "should have saved the attribute name" + assert_equal the_type.to_s, s.attrs['foo'], "should have saved the attribute type of: #{the_type}" + end + end + end + end + + test "attributes should not accept unknown values" do + bad_values = [ :oogle, :blob, 'thing'] + + Person.define_schema do |s| + bad_values.each do |bad_value| + assert_raises(ArgumentError,"should only accept a known attribute type, but accepted: #{bad_value.inspect}") do + s.attribute 'key', bad_value + end + assert !s.respond_to?(bad_value), "should only respond to a known attribute type, but accepted: #{bad_value.inspect}" + assert_raises(NoMethodError,"should only have methods for known attribute types, but accepted: #{bad_value.inspect}") do + s.send bad_value, 'key' + end + end + end + end + + + test "should accept attribute types as the type's name as the method" do + Person.define_schema do |s| + ActiveResource::SchemaDefinition::KNOWN_ATTRIBUTE_TYPES.each do |the_type| + assert s.respond_to?(the_type), "should recognise the attribute-type: #{the_type} as a method" + assert_nothing_raised("should take the method #{the_type} with the attribute name") do + s.send(the_type,'foo') # eg s.string :foo + end + assert s.attrs.has_key?('foo'), "should now have saved the attribute name" + assert_equal the_type.to_s, s.attrs['foo'], "should have saved the attribute type of: #{the_type}" + end + end + end + + test "should accept multiple attribute names for an attribute method" do + names = ['foo','bar','baz'] + Person.define_schema do |s| + assert_nothing_raised("should take strings with multiple attribute names as params") do + s.string( *names) + end + names.each do |the_name| + assert s.attrs.has_key?(the_name), "should now have saved the attribute name: #{the_name}" + assert_equal 'string', s.attrs[the_name], "should have saved the attribute as a string" + end + end + end + + ##################################################### + # What a schema does for us + #### + + # respond_to? + + test "should respond positively to attributes that are only in the schema" do + new_attr_name = :my_new_schema_attribute + new_attr_name_two = :another_new_schema_attribute + assert Person.schema.blank?, "sanity check - should have a blank class schema" + + assert !Person.new.respond_do?(new_attr_name), "sanity check - should not respond to the brand-new attribute yet" + assert !Person.new.respond_do?(new_attr_name_two), "sanity check - should not respond to the brand-new attribute yet" + + assert_nothing_raised do + Person.schema = {new_attr_name.to_s => 'string'} + Person.define_schema {|s| s.string new_attr_name_two } + end + + assert Person.new.respond_to?(new_attr_name), "should respond to the attribute in a passed-in schema, but failed on: #{new_attr_name}" + assert Person.new.respond_to?(new_attr_name_two), "should respond to the attribute from the define_schema, but failed on: #{new_attr_name_two}" + end + + test "should not care about ordering of schema definitions" do + new_attr_name = :my_new_schema_attribute + new_attr_name_two = :another_new_schema_attribute + + assert Person.schema.blank?, "sanity check - should have a blank class schema" + + assert !Person.new.respond_do?(new_attr_name), "sanity check - should not respond to the brand-new attribute yet" + assert !Person.new.respond_do?(new_attr_name_two), "sanity check - should not respond to the brand-new attribute yet" + + assert_nothing_raised do + Person.define_schema {|s| s.string new_attr_name_two } + Person.schema = {new_attr_name.to_s => 'string'} + end + + assert Person.new.respond_to?(new_attr_name), "should respond to the attribute in a passed-in schema, but failed on: #{new_attr_name}" + assert Person.new.respond_to?(new_attr_name_two), "should respond to the attribute from the define_schema, but failed on: #{new_attr_name_two}" + end + + # method_missing effects + + test "should not give method_missing for attribute only in schema" do + new_attr_name = :another_new_schema_attribute + new_attr_name_two = :another_new_schema_attribute + + assert Person.schema.blank?, "sanity check - should have a blank class schema" + + assert_raises(NoMethodError, "should not have found the attribute: #{new_attr_name} as a method") do + Person.new.send(new_attr_name) + end + assert_raises(NoMethodError, "should not have found the attribute: #{new_attr_name_two} as a method") do + Person.new.send(new_attr_name_two) + end + + Person.schema = {new_attr_name.to_s => :float} + Person.define_schema {|s| s.string new_attr_name_two } + + assert_nothing_raised do + Person.new.send(new_attr_name) + Person.new.send(new_attr_name_two) + end + end + + + ######## + # Known attributes + # + # Attributes can be known to be attributes even if they aren't actually + # 'set' on a particular instance. + # This will only differ from 'attributes' if a schema has been set. + + test "new model should have no known attributes" do + assert Person.known_attributes.blank?, "should have no known attributes" + assert Person.new.known_attributes.blank?, "should have no known attributes on a new instance" + end + + test "setting schema should set known attributes on class and instance" do + new_schema = {'age' => 'integer', 'name' => 'string'} + + assert_nothing_raised { Person.schema = new_schema } + + assert_equal new_schema.keys, Person.known_attributes + assert_equal new_schema.keys, Person.new.known_attributes + end + + test "known attributes on a fetched resource should return all the attributes of the instance" do + p = Person.find(1) + attrs = p.known_attributes + + assert attrs.present?, "should have found some attributes!" + + p.attributes.each do |the_attr, val| + assert attrs.include?(the_attr), "should have found attr: #{the_attr} in known attributes, but only had: #{attrs.inspect}" + end + end + + test "with two instances, known attributes should match the attributes of the individual instances - even if they differ" do + matz = Person.find(1) + rick = Person.find(5) + + m_attrs = matz.attributes.keys.sort + r_attrs = rick.attributes.keys.sort + + assert_not_equal m_attrs, r_attrs, "should have different attributes on each model" + + assert_not_equal matz.known_attributes, rick.known_attributes, "should have had different known attributes too" + end + + test "setting schema then fetching should add schema attributes to the intance attributes" do + # an attribute in common with fetched instance and one that isn't + new_schema = {'name' => 'string', 'my_strange_attribute' => 'string'} + + assert_nothing_raised { Person.schema = new_schema } + + matz = Person.find(1) + known_attrs = matz.known_attributes + + matz.attributes.keys.each do |the_attr| + assert known_attrs.include?(the_attr), "should have found instance attr: #{the_attr} in known attributes, but only had: #{known_attrs.inspect}" + end + new_schema.keys.each do |the_attr| + assert known_attrs.include?(the_attr), "should have found schema attr: #{the_attr} in known attributes, but only had: #{known_attrs.inspect}" + end + end + + +end -- cgit v1.2.3