smg 0.1.0 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (42) hide show
  1. data/README.rdoc +6 -62
  2. data/examples/discogs/label.rb +62 -0
  3. data/examples/discogs/search.rb +60 -0
  4. data/examples/plant.rb +1 -2
  5. data/examples/twitter.rb +8 -5
  6. data/examples/weather.rb +146 -0
  7. data/lib/smg.rb +4 -0
  8. data/lib/smg/document.rb +25 -8
  9. data/lib/smg/http.rb +66 -0
  10. data/lib/smg/http/exceptions.rb +37 -0
  11. data/lib/smg/http/request.rb +126 -0
  12. data/lib/smg/mapping.rb +11 -1
  13. data/lib/smg/mapping/element.rb +29 -13
  14. data/lib/smg/mapping/typecasts.rb +2 -1
  15. data/lib/smg/model.rb +9 -3
  16. data/lib/smg/resource.rb +5 -1
  17. data/spec/collect_spec.rb +254 -0
  18. data/spec/context_spec.rb +189 -0
  19. data/spec/extract_spec.rb +200 -0
  20. data/spec/filtering_spec.rb +164 -0
  21. data/spec/fixtures/discogs/948224.xml +1 -0
  22. data/spec/fixtures/discogs/Enzyme+Records.xml +9 -0
  23. data/spec/fixtures/discogs/Genosha+Recordings.xml +13 -0
  24. data/spec/fixtures/discogs/Ophidian.xml +6 -0
  25. data/spec/fixtures/fake/malus.xml +18 -0
  26. data/spec/fixtures/fake/valve.xml +8 -0
  27. data/spec/fixtures/twitter/pipopolam.xml +46 -0
  28. data/spec/fixtures/yahoo.weather.com.xml +50 -0
  29. data/spec/http/request_spec.rb +186 -0
  30. data/spec/http/shared/automatic.rb +43 -0
  31. data/spec/http/shared/non_automatic.rb +36 -0
  32. data/spec/http/shared/redirectable.rb +30 -0
  33. data/spec/http_spec.rb +76 -0
  34. data/spec/lib/helpers/http_helpers.rb +27 -0
  35. data/spec/lib/matchers/instance_methods.rb +38 -0
  36. data/spec/mapping/element_spec.rb +241 -0
  37. data/spec/mapping/typecasts_spec.rb +52 -0
  38. data/spec/resource_spec.rb +30 -0
  39. data/spec/root_spec.rb +26 -0
  40. data/spec/spec_helper.rb +23 -0
  41. metadata +53 -10
  42. data/examples/discogs.rb +0 -39
@@ -0,0 +1,66 @@
1
+ require 'smg/http/request'
2
+ require 'smg/http/exceptions'
3
+
4
+ module SMG #:nodoc:
5
+ module HTTP
6
+
7
+ module Model
8
+
9
+ def site(value)
10
+ @site = value
11
+ end
12
+
13
+ def params(value)
14
+ @params = value
15
+ end
16
+
17
+ def get(path, options = {}, &block)
18
+ http Net::HTTP::Get, path, options, &block
19
+ end
20
+
21
+ def head(path, options = {}, &block)
22
+ http Net::HTTP::Head, path, options, &block
23
+ end
24
+
25
+ def delete(path, options = {}, &block)
26
+ http Net::HTTP::Delete, path, options, &block
27
+ end
28
+
29
+ def post(path, options = {}, &block)
30
+ http Net::HTTP::Post, path, options, &block
31
+ end
32
+
33
+ def put(path, options = {}, &block)
34
+ http Net::HTTP::Put, path, options, &block
35
+ end
36
+
37
+ private
38
+
39
+ def http(verb, path, options = {})
40
+ raise "site URI missed" unless @site
41
+ opts = options.dup
42
+ uri = uri_for(path, opts.delete(:query))
43
+ response = SMG::HTTP::Request.new(verb, uri, opts).perform
44
+ parse block_given? ? yield(response) : response.body
45
+ end
46
+
47
+ def uri_for(path, query = nil)
48
+ ret = Addressable::URI.parse(@site)
49
+ ret.path = path
50
+ qvalues = {}
51
+ qvalues.update(@params) if @params
52
+ qvalues.update(query) if query
53
+ ret.query_values = qvalues unless qvalues.empty?
54
+ ret
55
+ end
56
+
57
+ end
58
+
59
+ def self.append_features(base)
60
+ base.extend Model
61
+ end
62
+
63
+ end
64
+ end
65
+
66
+ # EOF
@@ -0,0 +1,37 @@
1
+ module SMG #:nodoc:
2
+ module HTTP #:nodoc:
3
+
4
+ class ConnectionError < StandardError
5
+
6
+ def initialize(response, message = nil)
7
+ @response = response
8
+ @message = message
9
+ end
10
+
11
+ def to_s
12
+ message = "Action failed with code: #{@response.code}."
13
+ message << " Message: #{@response.message}" if @response.respond_to?(:message)
14
+ message
15
+ end
16
+
17
+ end
18
+
19
+ class RedirectionError < ConnectionError
20
+ end
21
+
22
+ class TimeoutError < ConnectionError
23
+
24
+ def initialize(message)
25
+ @message = message
26
+ end
27
+
28
+ def to_s
29
+ @message
30
+ end
31
+
32
+ end
33
+
34
+ end
35
+ end
36
+
37
+ # EOF
@@ -0,0 +1,126 @@
1
+ require 'net/http'
2
+
3
+ gem 'addressable', '>= 2.1.1'
4
+ require 'addressable/uri'
5
+
6
+ module SMG #:nodoc:
7
+ module HTTP #:nodoc:
8
+ class Request
9
+
10
+ attr_reader :verb, :uri, :headers, :body, :limit, :timeout, :proxy
11
+
12
+ DEFAULT_LIMIT = 5
13
+
14
+ VERBS = [Net::HTTP::Get, Net::HTTP::Post, Net::HTTP::Put, Net::HTTP::Delete, Net::HTTP::Head]
15
+
16
+ def initialize(verb, uri, options = {})
17
+ raise "unknown verb: #{verb}" unless VERBS.include?(verb)
18
+
19
+ @verb = verb
20
+ @uri = Addressable::URI.parse(uri)
21
+ @proxy = options[:proxy] ? Addressable::URI.parse(options[:proxy]) : nil
22
+ @headers = options[:headers] ? options[:headers].to_hash : {}
23
+ @limit = options[:no_follow] ? 1 : options[:limit] || DEFAULT_LIMIT
24
+ @body = options[:body]
25
+ @timeout = options[:timeout] ? options[:timeout].to_i : nil
26
+ end
27
+
28
+ def perform
29
+ setup
30
+ response = http.request(@request)
31
+ handle_response(response)
32
+ rescue Timeout::Error => e
33
+ raise TimeoutError, e.message
34
+ end
35
+
36
+ protected
37
+
38
+ #--
39
+ # 10.3 Redirection 3xx
40
+ # This class of status code indicates that further action needs to be
41
+ # taken by the user agent in order to fulfill the request. The action
42
+ # required MAY be carried out by the user agent without interaction
43
+ # with the user if and only if the method used in the second request is
44
+ # GET or HEAD. A client SHOULD detect infinite redirection loops, since
45
+ # such loops generate network traffic for each redirection.
46
+ #
47
+ # If the (301, 302 or 307) status code is received in response to a request other
48
+ # than GET or HEAD, the user agent MUST NOT automatically redirect the
49
+ # request unless it can be confirmed by the user, since this might
50
+ # change the conditions under which the request was issued.
51
+ #
52
+ # 10.3.1 300 Multiple Choices
53
+ # If the server has a preferred choice of representation, it SHOULD
54
+ # include the specific URI for that representation in the Location
55
+ # field; user agents MAY use the Location field value for automatic
56
+ # redirection.
57
+ #
58
+ # 10.3.4 303 See Other
59
+ # The response to the request can be found under a different URI and
60
+ # SHOULD be retrieved using a GET method on that resource.
61
+ #
62
+ # 10.3.6 305 Use Proxy
63
+ # The requested resource MUST be accessed through the proxy given by
64
+ # the Location field. The Location field gives the URI of the proxy.
65
+ # The recipient is expected to repeat this single request via the
66
+ # proxy. 305 responses MUST only be generated by origin servers.
67
+ #++
68
+
69
+ def perform_redirection(response)
70
+ location = response["Location"]
71
+ raise RedirectionError.new(response, "Location field-value missed") unless location
72
+
73
+ case response
74
+ when Net::HTTPUseProxy; @proxy = Addressable::URI.parse(location)
75
+ when Net::HTTPSeeOther; @verb, @uri = Net::HTTP::Get, Addressable::URI.parse(location)
76
+ else
77
+ raise RedirectionError.new(response, "automatical redirection is NOT allowed") unless
78
+ @verb == Net::HTTP::Get ||
79
+ @verb == Net::HTTP::Head
80
+ @uri = Addressable::URI.parse(location)
81
+ end
82
+
83
+ perform
84
+ end
85
+
86
+ def handle_response(response)
87
+ case response
88
+ when Net::HTTPOK
89
+ response
90
+ when Net::HTTPMultipleChoice, # 300
91
+ Net::HTTPMovedPermanently, # 301
92
+ Net::HTTPFound, # 302
93
+ Net::HTTPSeeOther, # 303
94
+ Net::HTTPUseProxy, # 305
95
+ Net::HTTPTemporaryRedirect # 307
96
+
97
+ raise RedirectionError.new(response, "redirection level too deep") unless (@limit -= 1) > 0
98
+ perform_redirection(response)
99
+ else
100
+ raise ConnectionError.new(response)
101
+ end
102
+ end
103
+
104
+ def setup
105
+ @request = verb.new(@uri.request_uri, @headers)
106
+ @request.basic_auth(@uri.user, @uri.password) if @uri.user && @uri.password
107
+ @request.body = @body if @body
108
+ nil
109
+ end
110
+
111
+ def http
112
+ http = @proxy ?
113
+ Net::HTTP.new(@uri.host, @uri.port, @proxy.host, @proxy.port, @proxy.user, @proxy.password) :
114
+ Net::HTTP.new(@uri.host, @uri.port)
115
+
116
+ return http unless @timeout
117
+ http.open_timeout = @timeout
118
+ http.read_timeout = @timeout
119
+ http
120
+ end
121
+
122
+ end
123
+ end
124
+ end
125
+
126
+ # EOF
@@ -3,6 +3,7 @@ module SMG #:nodoc:
3
3
 
4
4
  attr_reader :elements, :nested, :attributes
5
5
  attr_reader :root
6
+ attr_reader :parsed
6
7
 
7
8
  def initialize
8
9
  @elements = {}
@@ -33,10 +34,19 @@ module SMG #:nodoc:
33
34
  @root = normalize_path(path)
34
35
  end
35
36
 
37
+ def refresh!
38
+ @parsed ||= []
39
+ @parsed.clear
40
+ nil
41
+ end
42
+
36
43
  private
37
44
 
38
45
  def normalize_path(path)
39
- path.to_s.split("/")
46
+ path = path.to_s.squeeze("/")
47
+ path = path[0..-2] if path[-1] == ?/
48
+ path = path[1..-1] if path[0] == ?/
49
+ path.split("/")
40
50
  end
41
51
 
42
52
  def handle_path(path)
@@ -2,46 +2,47 @@ module SMG #:nodoc:
2
2
  class Mapping #:nodoc:
3
3
 
4
4
  class Element
5
- attr_reader :path, :name, :accessor, :data_class, :cast_to, :at, :context
5
+ attr_reader :path, :name, :accessor, :data_class, :cast_to, :at, :context, :with
6
6
 
7
7
  def initialize(path, options = {})
8
8
 
9
- @name = (options[:as] || options[:at] || path.last).to_sym
9
+ @name = (options[:as] || options[:at] || path.last.gsub(":","_")).to_sym
10
10
  @path = path
11
11
  @collection = !!options[:collection]
12
+ @with = options[:with] ? normalize_conditions(options[:with]) : nil
12
13
  @accessor = @collection ? :"append_to_#{@name}" : :"#{@name}="
13
14
  @data_class = nil
14
15
  @cast_to = nil
15
16
  @context = nil
16
17
 
17
18
  if options.key?(:context)
18
- if Array === options[:context]
19
- @context = options[:context].compact
20
- @context.uniq!
21
- @context = nil if @context.empty?
22
- else
23
- raise ArgumentError, ":context should be an Array"
24
- end
19
+ raise ArgumentError, "+options[:context]+ should be an Array of Symbols" unless
20
+ Array === options[:context] &&
21
+ options[:context].all?{ |c| Symbol === c }
22
+
23
+ @context = options[:context].compact
24
+ @context.uniq!
25
+ @context = nil if @context.empty?
25
26
  end
26
27
 
27
28
  if options.key?(:class)
28
29
  klass = options[:class]
29
30
  if SMG::Model === klass
30
31
  @data_class = klass
31
- elsif TypeCasts.key?(klass)
32
+ elsif SMG::Mapping::TypeCasts.key?(klass)
32
33
  @cast_to = klass
33
34
  else
34
- raise ArgumentError, ":class should be an SMG::Model or a valid typecast"
35
+ raise ArgumentError, "+options[:class]+ should be an SMG::Model or a valid typecast"
35
36
  end
36
37
  end
37
38
 
38
- #ignore :at on nested collections of resources
39
+ #ignore options[:at] on nested collections of resources
39
40
  @at = options.key?(:at) && !@data_class ? options[:at].to_s : nil
40
41
 
41
42
  end
42
43
 
43
44
  def cast(value)
44
- @cast_to ? ::SMG::Mapping::TypeCasts[@cast_to, value] : value
45
+ @cast_to ? SMG::Mapping::TypeCasts[@cast_to, value] : value
45
46
  rescue
46
47
  raise ArgumentError, "#{value.inspect} is not a valid source for #{@cast_to.inspect}"
47
48
  end
@@ -54,6 +55,21 @@ module SMG #:nodoc:
54
55
  @context.nil? || @context.include?(context)
55
56
  end
56
57
 
58
+ def with?(attrh)
59
+ @with.nil? || @with.all? { |k,v| attrh[k] == v }
60
+ end
61
+
62
+ private
63
+
64
+ def normalize_conditions(conditions)
65
+ ret = {}
66
+ conditions.each do |k,v|
67
+ v = v.to_s unless v.nil?
68
+ ret[k.to_s] = v
69
+ end
70
+ ret
71
+ end
72
+
57
73
  end
58
74
 
59
75
  end
@@ -24,7 +24,8 @@ module SMG #:nodoc:
24
24
  self.typecasts = {}
25
25
  self.typecasts[ :string ] = lambda{ |v| v.to_s }
26
26
  self.typecasts[ :integer ] = lambda{ |v| v.to_i }
27
- self.typecasts[ :boolean ] = lambda{ |v| v.to_s.strip != 'false' }
27
+ self.typecasts[ :float ] = lambda{ |v| v.to_f }
28
+ self.typecasts[ :boolean ] = lambda{ |v| v.nil? ? nil : (v.to_s.strip != 'false') }
28
29
  self.typecasts[ :symbol ] = lambda{ |v| v.to_sym }
29
30
  self.typecasts[ :datetime ] = lambda{ |v| Time.parse(v).utc }
30
31
  self.typecasts[ :date ] = lambda{ |v| Date.parse(v) }
@@ -44,10 +44,16 @@ module SMG #:nodoc:
44
44
  end
45
45
 
46
46
  def parse(data, context = nil)
47
- resource = new
48
- doc = SMG::Document.new(resource,context)
47
+ doc = SMG::Document.new(resource = new,context)
49
48
  ::Nokogiri::XML::SAX::Parser.new(doc).parse(data)
50
- resource.instance_variable_set(:@_parsed, true)
49
+ resource.parsed!
50
+ resource
51
+ end
52
+
53
+ def parse_file(fname, context = nil)
54
+ doc = SMG::Document.new(resource = new,context)
55
+ ::Nokogiri::XML::SAX::Parser.new(doc).parse_file(fname)
56
+ resource.parsed!
51
57
  resource
52
58
  end
53
59
 
@@ -6,7 +6,11 @@ module SMG #:nodoc:
6
6
  end
7
7
 
8
8
  def parsed?
9
- @_parsed
9
+ @_parsed ||= false
10
+ end
11
+
12
+ def parsed!
13
+ @_parsed = true
10
14
  end
11
15
 
12
16
  end
@@ -0,0 +1,254 @@
1
+ # encoding: utf-8
2
+
3
+ require File.expand_path File.join(File.dirname(__FILE__), 'spec_helper')
4
+
5
+ describe SMG::Model, ".collect" do
6
+
7
+ include Spec::Matchers::HaveInstanceMethodMixin
8
+
9
+ before :each do
10
+ @klass = Class.new { include SMG::Resource }
11
+ @data = data = File.read(FIXTURES_DIR + 'discogs/Genosha+Recordings.xml')
12
+ end
13
+
14
+ it "defines appropriate reader and writer" do
15
+ @klass.root 'resp/label'
16
+ @klass.collect 'releases/release/catno', :as => :catalogue_numbers
17
+ @klass.should have_instance_method 'catalogue_numbers'
18
+ @klass.should have_instance_method 'append_to_catalogue_numbers'
19
+ end
20
+
21
+ it "never overrides readers" do
22
+ @klass.root 'resp/label'
23
+ @klass.collect 'urls/url', :as => :urls
24
+ @klass.class_eval <<-CODE
25
+ def urls
26
+ unless @urls.nil?
27
+ @urls.map{|u| u.empty? ? nil : u.strip}.compact.join(', ')
28
+ end
29
+ end
30
+ CODE
31
+ label = @klass.parse(@data)
32
+ label.urls.should == 'http://www.genosharecordings.com/, http://www.myspace.com/genosharecordings'
33
+ end
34
+
35
+ it "never overrides writers" do
36
+ @klass.root 'resp/label'
37
+ @klass.collect 'urls/url', :as => :urls
38
+ @klass.class_eval <<-CODE
39
+ def append_to_urls(value)
40
+ unless value.nil? || value.empty?
41
+ @urls ||= []
42
+ @urls << URI.parse(value)
43
+ end
44
+ end
45
+ CODE
46
+ label = @klass.parse(@data)
47
+ label.urls.should be_an_instance_of ::Array
48
+ label.urls.should == [
49
+ URI.parse('http://www.genosharecordings.com/'),
50
+ URI.parse('http://www.myspace.com/genosharecordings')
51
+ ]
52
+ end
53
+
54
+ end
55
+
56
+ describe SMG::Model, ".collect", "without :class option" do
57
+
58
+ before :each do
59
+ @klass = Class.new { include SMG::Resource }
60
+ @data = data = File.read(FIXTURES_DIR + 'discogs/Genosha+Recordings.xml')
61
+ end
62
+
63
+ it "collects texts" do
64
+ @klass.root 'resp/label'
65
+ @klass.collect 'releases/release/catno', :as => :catalogue_numbers
66
+ label = @klass.parse(@data)
67
+ label.catalogue_numbers.should be_an_instance_of ::Array
68
+ label.catalogue_numbers.should == ["GEN 001", "GEN 001", "GEN 002", "GEN 003", "GEN 006", "GEN 008", "GEN 008", "GEN 009", "GEN 012", "GEN 013", "GEN 015", "GEN004", "GEN005", "GEN006½", "GEN007", "GEN009", "GEN010", "GEN011", "GEN012", "GEN014", "GEN016", "GEN017", "GENCD01", "GENOSHA 018"]
69
+ end
70
+
71
+ it "collects attributes" do
72
+ @klass.root 'resp/label'
73
+ @klass.collect 'releases/release', :at => :id , :as => :release_ids
74
+ @klass.collect 'releases/release', :at => :status , :as => :release_statuses
75
+ label = @klass.parse(@data)
76
+ label.release_ids.should be_an_instance_of ::Array
77
+ label.release_statuses.should be_an_instance_of ::Array
78
+ label.release_ids.should == ["183713", "1099277", "183735", "225253", "354681", "1079143", "448035", "1083336", "1079145", "814757", "964449", "254166", "341855", "387611", "396345", "448709", "529057", "662611", "683859", "915651", "1021944", "1494949", "354683", "1825580"]
79
+ label.release_statuses.should == ["Accepted"]*24
80
+ end
81
+
82
+ it "is able to build multiple datasets" do
83
+ custom_xml = <<-XML
84
+ <releases>
85
+ <release id="2259548" status="Accepted" type="TrackAppearance">Signal Flow Podcast 03</release>
86
+ <release id="2283715" status="Accepted" type="TrackAppearance">United Hardcore Forces</release>
87
+ <release id="2283652" status="Accepted" type="TrackAppearance">Warp Madness</release>
88
+ <release id="1775742" status="Accepted" type="UnofficialRelease">Stalker 2.9 Level 3 Compilation</release>
89
+ </release>
90
+ XML
91
+
92
+ @klass.collect 'releases/release', :at => :id, :as => :ids
93
+ @klass.collect 'releases/release', :at => :type, :as => :types
94
+ @klass.collect 'releases/release', :as => :titles
95
+
96
+ collection = @klass.parse(custom_xml)
97
+ collection.ids.should == ["2259548", "2283715", "2283652", "1775742"]
98
+ collection.types.should == ["TrackAppearance", "TrackAppearance", "TrackAppearance", "UnofficialRelease"]
99
+ collection.titles.should == ["Signal Flow Podcast 03", "United Hardcore Forces", "Warp Madness", "Stalker 2.9 Level 3 Compilation"]
100
+ end
101
+
102
+ it "collects nothing when there's no matching elements" do
103
+ @klass.root 'resp/label'
104
+ @klass.collect 'releases/release', :at => :bogus, :as => :release_ids
105
+ label = @klass.parse(@data)
106
+ label.release_ids.should be_an_instance_of ::Array
107
+ label.release_ids.should be_empty
108
+ end
109
+
110
+ end
111
+
112
+ describe SMG::Model, ".collect", "when :class option represents SMG::Resource" do
113
+
114
+ before :each do
115
+ @klass = Class.new { include SMG::Resource }
116
+ @data = data = File.read(FIXTURES_DIR + 'discogs/Genosha+Recordings.xml')
117
+ end
118
+
119
+ before :all do
120
+ @release_class = Class.new { include SMG::Resource }
121
+ @release_class.extract 'release' , :at => :id , :as => :discogs_id
122
+ @release_class.extract 'release' , :at => :status
123
+ @release_class.extract 'release/title'
124
+
125
+ @image_class = Class.new { include SMG::Resource }
126
+ @image_class.extract 'image', :at => :uri
127
+ @image_class.extract 'image', :at => :uri150, :as => :preview
128
+ @image_class.extract 'image', :at => :width
129
+ @image_class.extract 'image', :at => :height
130
+ end
131
+
132
+ it "collects resources" do
133
+ @klass.root 'resp/label'
134
+ @klass.collect 'releases/release', :as => :releases, :class => @release_class
135
+ label = @klass.parse(@data)
136
+ label.releases.should be_an_instance_of ::Array
137
+ label.releases.size.should == 24
138
+ label.releases[8].title.should == "No, We Don't Want You To Clap Your Fucking Hands"
139
+ label.releases[8].discogs_id.should == "1079145"
140
+ label.releases[8].status.should == "Accepted"
141
+ end
142
+
143
+ it "is able to build multiple collections" do
144
+ @klass.root 'resp/label'
145
+ @klass.collect 'releases/release' , :as => :releases , :class => @release_class
146
+ @klass.collect 'images/image' , :as => :images , :class => @image_class
147
+ @klass.collect 'releases/release' , :as => :release_ids , :at => :id
148
+ label = @klass.parse(@data)
149
+
150
+ label.releases.should be_an_instance_of ::Array
151
+ label.releases.size.should == 24
152
+ label.releases[8].should be_an_instance_of @release_class
153
+ label.releases[8].title.should == "No, We Don't Want You To Clap Your Fucking Hands"
154
+ label.releases[8].discogs_id.should == "1079145"
155
+
156
+ label.images.should be_an_instance_of ::Array
157
+ label.images.size.should == 2
158
+ label.images[1].should be_an_instance_of @image_class
159
+ label.images[1].width.should == '600'
160
+ label.images[1].height.should == '159'
161
+ label.images[1].uri.should == 'http://www.discogs.com/image/L-16366-1165574398.jpeg'
162
+ label.images[1].preview.should == 'http://www.discogs.com/image/L-150-16366-1165574398.jpeg'
163
+
164
+ label.release_ids.should == ["183713", "1099277", "183735", "225253", "354681", "1079143", "448035", "1083336", "1079145", "814757", "964449", "254166", "341855", "387611", "396345", "448709", "529057", "662611", "683859", "915651", "1021944", "1494949", "354683", "1825580"]
165
+ end
166
+
167
+ it "collects nothing when there's no matching elements" do
168
+ @klass.root 'resp/label'
169
+ @klass.collect 'releases/bogus', :as => :releases, :class => @release_class
170
+ label = @klass.parse(@data)
171
+ label.releases.should be_an_instance_of ::Array
172
+ label.releases.should be_empty
173
+ end
174
+
175
+ end
176
+
177
+ describe SMG::Model, ".collect", "when :class option represents built-in typecast" do
178
+
179
+ before :each do
180
+ @klass = Class.new { include SMG::Resource }
181
+ @data = data = File.read(FIXTURES_DIR + 'discogs/Genosha+Recordings.xml')
182
+ end
183
+
184
+ it "makes an attempt to perform a typecast" do
185
+ Class.new { include SMG::Resource }
186
+ @klass.root 'resp/label'
187
+ @klass.collect 'releases/release', :at => :id, :class => :integer, :as => :release_ids
188
+ label = @klass.parse(@data)
189
+ label.release_ids.should == [183713, 1099277, 183735, 225253, 354681, 1079143, 448035, 1083336, 1079145, 814757, 964449, 254166, 341855, 387611, 396345, 448709, 529057, 662611, 683859, 915651, 1021944, 1494949, 354683, 1825580]
190
+ end
191
+
192
+ it "raises an ArgumentError if typecasting fails" do
193
+ Class.new { include SMG::Resource }
194
+ @klass.root 'resp/label'
195
+ @klass.collect 'releases/release', :at => :id, :class => :datetime, :as => :release_ids
196
+ lambda { @klass.parse(@data) }.
197
+ should raise_error ArgumentError, %r{"183713" is not a valid source for :datetime}
198
+ end
199
+
200
+ end
201
+
202
+ describe SMG::Model, ".collect", "with nested collections" do
203
+
204
+ before :each do
205
+ @klass = Class.new { include SMG::Resource }
206
+ @data = data = File.read(FIXTURES_DIR + 'discogs/948224.xml')
207
+ end
208
+
209
+ before :all do
210
+ @artist_class = Class.new { include SMG::Resource }
211
+ @artist_class.root 'artist'
212
+ @artist_class.extract :name
213
+ @artist_class.extract :role
214
+
215
+ @track_class = Class.new { include SMG::Resource }
216
+ @track_class.root 'track'
217
+ @track_class.extract :position
218
+ @track_class.extract :title
219
+ @track_class.extract :duration
220
+ @track_class.collect 'extraartists/artist/name', :as => :extra_artist_names
221
+ @track_class.collect 'extraartists/artist', :as => :extra_artists, :class => @artist_class
222
+ end
223
+
224
+ it "supports nested collections" do
225
+ @klass.root 'resp/release'
226
+ @klass.collect 'tracklist/track' , :as => :tracks, :class => @track_class
227
+ @klass.collect 'tracklist/track/title' , :as => :tracklist
228
+ release = @klass.parse(@data)
229
+
230
+ release.tracklist.should be_an_instance_of ::Array
231
+ release.tracklist.size.should == 6
232
+ release.tracklist.should == ["Butterfly V.I.P. (VIP Edit By Ophidian)", "Butterfly V.I.P. (Interlude By Cubist Boy & Tapage)", "Butterfly V.I.P. (Original Version)", "Hammerhead V.I.P. (VIP Edit By Ophidian)", "Hammerhead V.I.P. (Interlude By Cubist Boy & Tapage)", "Hammerhead V.I.P. (Original Version)"]
233
+
234
+ release.tracks.should be_an_instance_of ::Array
235
+ release.tracks.size.should == 6
236
+
237
+ track = release.tracks[1]
238
+ track.position.should == 'A2'
239
+ track.title.should == 'Butterfly V.I.P. (Interlude By Cubist Boy & Tapage)'
240
+ track.duration.should == '1:24'
241
+
242
+ track.extra_artist_names.should be_an_instance_of ::Array
243
+ track.extra_artist_names.size.should == 2
244
+ track.extra_artist_names.should == ['Cubist Boy', 'Tapage']
245
+
246
+ track.extra_artists.should be_an_instance_of ::Array
247
+ track.extra_artists.size.should == 2
248
+ track.extra_artists[1].name.should == 'Tapage'
249
+ track.extra_artists[1].role.should == 'Co-producer'
250
+ end
251
+
252
+ end
253
+
254
+ # EOF