smg 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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