perry 0.4.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.
data/README.rdoc ADDED
@@ -0,0 +1,38 @@
1
+ = Perry
2
+
3
+ == Description
4
+
5
+ Ruby library for querying and mapping data through generic interfaces
6
+
7
+ == Installation
8
+
9
+ gem install perry
10
+
11
+ == Usage
12
+
13
+ require 'perry'
14
+
15
+ == License
16
+
17
+ Copyright (c) 2011 Travis Petticrew
18
+
19
+ Permission is hereby granted, free of charge, to any person
20
+ obtaining a copy of this software and associated documentation
21
+ files (the "Software"), to deal in the Software without
22
+ restriction, including without limitation the rights to use,
23
+ copy, modify, merge, publish, distribute, sublicense, and/or sell
24
+ copies of the Software, and to permit persons to whom the
25
+ Software is furnished to do so, subject to the following
26
+ conditions:
27
+
28
+ The above copyright notice and this permission notice shall be
29
+ included in all copies or substantial portions of the Software.
30
+
31
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
32
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
33
+ OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
34
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
35
+ HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
36
+ WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
37
+ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
38
+ OTHER DEALINGS IN THE SOFTWARE.
data/Rakefile ADDED
@@ -0,0 +1,70 @@
1
+ require 'rubygems'
2
+ require 'rake/gempackagetask'
3
+ require 'rake/testtask'
4
+
5
+ require 'lib/perry/version'
6
+
7
+ spec = Gem::Specification.new do |s|
8
+ s.name = 'perry'
9
+ s.version = Perry::Version.to_s
10
+ s.has_rdoc = true
11
+ s.extra_rdoc_files = %w(README.rdoc)
12
+ s.rdoc_options = %w(--main README.rdoc)
13
+ s.summary = "Ruby library for querying and mapping data through generic interfaces"
14
+ s.author = 'Travis Petticrew'
15
+ s.email = 'bobo@petticrew.net'
16
+ s.homepage = 'http://github.com/tpett/perry'
17
+ s.files = %w(README.rdoc Rakefile) + Dir.glob("{lib}/**/*")
18
+ # s.executables = ['perry']
19
+
20
+ s.add_development_dependency("shoulda", [">= 2.10.0"])
21
+ s.add_development_dependency("leftright", [">= 0.0.6"])
22
+ s.add_development_dependency("fakeweb", [">= 1.3.0"])
23
+ s.add_development_dependency("factory_girl", [">= 0"])
24
+
25
+ s.add_dependency("activesupport", [">= 2.3.0"])
26
+ s.add_dependency("bertrpc", [">= 1.3.0"])
27
+ end
28
+
29
+ Rake::GemPackageTask.new(spec) do |pkg|
30
+ pkg.gem_spec = spec
31
+ end
32
+
33
+ [:rails2, :rails3].each do |name|
34
+
35
+ Rake::TestTask.new("test_#{name}") do |t|
36
+ t.libs << 'test'
37
+ t.ruby_opts << "-r load_#{name}"
38
+ t.test_files = FileList["test/**/*_test.rb"]
39
+ t.verbose = true
40
+ end
41
+
42
+ end
43
+
44
+ desc "Test all gem versions"
45
+ task :test => [:test_rails2, :test_rails3] do
46
+ end
47
+
48
+ begin
49
+ require 'rcov/rcovtask'
50
+
51
+ Rcov::RcovTask.new(:coverage) do |t|
52
+ t.libs = ['test']
53
+ t.test_files = FileList["test/**/*_test.rb"]
54
+ t.verbose = true
55
+ t.rcov_opts = ['--text-report', "-x #{Gem.path}", '-x /Library/Ruby', '-x /usr/lib/ruby']
56
+ end
57
+
58
+ task :default => :coverage
59
+
60
+ rescue LoadError
61
+ warn "\n**** Install rcov (sudo gem install relevance-rcov) to get coverage stats ****\n"
62
+ task :default => :test
63
+ end
64
+
65
+ desc 'Generate the gemspec to serve this gem'
66
+ task :gemspec do
67
+ file = File.dirname(__FILE__) + "/#{spec.name}.gemspec"
68
+ File.open(file, 'w') {|f| f << spec.to_ruby }
69
+ puts "Created gemspec: #{file}"
70
+ end
data/lib/perry.rb ADDED
@@ -0,0 +1,48 @@
1
+ # TRP: Cherry pick some goodies from active_support
2
+ require 'active_support/core_ext/array'
3
+ require 'active_support/core_ext/class/inheritable_attributes'
4
+ require 'active_support/core_ext/hash/deep_merge'
5
+ begin
6
+ require 'active_support/core_ext/duplicable' #ActiveSupport 2.3.x
7
+ Hash.send(:include, ActiveSupport::CoreExtensions::Hash::DeepMerge) unless Hash.instance_methods.include?('deep_merge')
8
+ rescue LoadError => exception
9
+ require 'active_support/core_ext/object/duplicable' #ActiveSupport 3.0.x
10
+ end
11
+ require 'active_support/core_ext/module/delegation'
12
+
13
+ # TRP: Used for pretty logging
14
+ autoload :Benchmark, 'benchmark'
15
+
16
+ require 'ostruct'
17
+
18
+ # TRP: Perry core_ext
19
+ require 'perry/core_ext/kernel/singleton_class'
20
+
21
+ module Perry
22
+ @@log_file = nil
23
+
24
+ def self.logger
25
+ @@logger ||= default_logger
26
+ end
27
+
28
+ def self.logger=(logger)
29
+ @@logger = logger
30
+ end
31
+
32
+ def self.log_file=(file)
33
+ @@log_file = file
34
+ end
35
+
36
+ def self.default_logger
37
+ if defined?(Rails)
38
+ Rails.logger
39
+ else
40
+ require 'logger' unless defined?(::Logger)
41
+ ::Logger.new(@@log_file)
42
+ end
43
+ end
44
+
45
+ end
46
+
47
+ require 'perry/base'
48
+
@@ -0,0 +1,5 @@
1
+ module Perry::Adapters; end
2
+
3
+ require 'perry/adapters/abstract_adapter'
4
+ require 'perry/adapters/bertrpc_adapter'
5
+ require 'perry/adapters/restful_http_adapter'
@@ -0,0 +1,92 @@
1
+ require 'perry/logger'
2
+
3
+ module Perry::Adapters
4
+ class AbstractAdapter
5
+ include Perry::Logger
6
+
7
+ attr_accessor :config
8
+ attr_reader :type
9
+ @@registered_adapters ||= {}
10
+
11
+ def initialize(type, config)
12
+ @type = type.to_sym
13
+ @configuration_contexts = config.is_a?(Array) ? config : [config]
14
+ end
15
+
16
+ def self.create(type, config)
17
+ klass = @@registered_adapters[type.to_sym]
18
+ klass.new(type, config)
19
+ end
20
+
21
+ def extend_adapter(config)
22
+ config = config.is_a?(Array) ? config : [config]
23
+ self.class.create(self.type, @configuration_contexts + config)
24
+ end
25
+
26
+ def config
27
+ @config ||= build_configuration
28
+ end
29
+
30
+ def call(mode, options)
31
+ @stack ||= self.middlewares.reverse.inject(self.method(mode)) do |below, (above_klass, above_config)|
32
+ above_klass.new(below, above_config)
33
+ end
34
+
35
+ @stack.call(options)
36
+ end
37
+
38
+ def middlewares
39
+ self.config[:middlewares] || []
40
+ end
41
+
42
+ def read(options)
43
+ raise(NotImplementedError,
44
+ "You must not use the abstract adapter. Implement an adapter that extends the " +
45
+ "Perry::Adapters::AbstractAdapter class and overrides this method.")
46
+ end
47
+
48
+ def write(object)
49
+ raise(NotImplementedError,
50
+ "You must not use the abstract adapter. Implement an adapter that extends the " +
51
+ "Perry::Adapters::AbstractAdapter class and overrides this method.")
52
+ end
53
+
54
+ def delete(object)
55
+ raise(NotImplementedError,
56
+ "You must not use the abstract adapter. Implement an adapter that extends the " +
57
+ "Perry::Adapters::AbstractAdapter class and overrides this method.")
58
+ end
59
+
60
+ def self.register_as(name)
61
+ @@registered_adapters[name.to_sym] = self
62
+ end
63
+
64
+ private
65
+
66
+ # TRP: Run each configure block in order of class hierarchy / definition and merge the results.
67
+ def build_configuration
68
+ @configuration_contexts.inject({}) do |sum, config|
69
+ if config.is_a?(Hash)
70
+ sum.merge(config)
71
+ else
72
+ AdapterConfig.new(sum).tap { |ac| config.call(ac) }.marshal_dump
73
+ end
74
+ end
75
+ end
76
+
77
+ class AdapterConfig < OpenStruct
78
+
79
+ def add_middleware(klass, config={})
80
+ self.middlewares ||= []
81
+ self.middlewares << [klass, config]
82
+ end
83
+
84
+ def to_hash
85
+ marshal_dump
86
+ end
87
+
88
+ end
89
+
90
+ end
91
+ end
92
+
@@ -0,0 +1,32 @@
1
+ autoload :BERTRPC, 'bertrpc'
2
+
3
+ module Perry::Adapters
4
+ class BERTRPCAdapter < Perry::Adapters::AbstractAdapter
5
+ register_as :bertrpc
6
+
7
+ @@service_pool ||= {}
8
+
9
+ def service
10
+ @@service_pool["#{config[:host]}:#{config[:port]}"] ||=
11
+ BERTRPC::Service.new(self.config[:host], self.config[:port])
12
+ end
13
+
14
+ def read(options)
15
+ log(options, "RPC #{config[:service]}") {
16
+ self.service.call.send(self.namespace).send(self.service_name,
17
+ options.merge(config[:default_options] || {}))
18
+ }
19
+ end
20
+
21
+ protected
22
+
23
+ def namespace
24
+ self.config[:namespace]
25
+ end
26
+
27
+ def service_name
28
+ self.config[:service]
29
+ end
30
+
31
+ end
32
+ end
@@ -0,0 +1,99 @@
1
+ autoload :Net, 'net/http'
2
+ autoload :URI, 'uri'
3
+
4
+ module Perry::Adapters
5
+ class RestfulHTTPAdapter < Perry::Adapters::AbstractAdapter
6
+ register_as :restful_http
7
+
8
+ attr_reader :last_response
9
+
10
+ def initialize(*args)
11
+ super
12
+ @configuration_contexts << { :primary_key => :id }
13
+ end
14
+
15
+ def write(object)
16
+ params = build_params_from_attributes(object)
17
+ object.new_record? ? post_http(object, params) : put_http(object, params)
18
+ end
19
+
20
+ def delete(object)
21
+ delete_http(object)
22
+ end
23
+
24
+ protected
25
+
26
+ def post_http(object, params)
27
+ http_call(object, :post, params)
28
+ end
29
+
30
+ def put_http(object, params)
31
+ http_call(object, :put, params)
32
+ end
33
+
34
+ def delete_http(object)
35
+ http_call(object, :delete, self.config[:default_options])
36
+ end
37
+
38
+ def http_call(object, method, params={})
39
+ request_klass = case method
40
+ when :post then Net::HTTP::Post
41
+ when :put then Net::HTTP::Put
42
+ when :delete then Net::HTTP::Delete
43
+ end
44
+
45
+ req_uri = self.build_uri(object, method)
46
+
47
+ request = if method == :delete
48
+ request = request_klass.new([req_uri.path, req_uri.query].join('?'))
49
+ else
50
+ request = request_klass.new(req_uri.path)
51
+ request.set_form_data(params) unless method == :delete
52
+ request
53
+ end
54
+
55
+ self.log(params, "#{method.to_s.upcase} #{req_uri}") do
56
+ @last_response = Net::HTTP.new(req_uri.host, req_uri.port).start { |http| http.request(request) }
57
+ end
58
+ parse_response_code(@last_response)
59
+ end
60
+
61
+ def parse_response_code(response)
62
+ case response
63
+ when Net::HTTPSuccess, Net::HTTPRedirection
64
+ true
65
+ else
66
+ false
67
+ end
68
+ end
69
+
70
+ def build_params_from_attributes(object)
71
+ if self.config[:post_body_wrapper]
72
+ params = self.config[:default_options] || {}
73
+ params.merge!(object.write_options[:default_options]) if object.write_options.is_a?(Hash) && object.write_options[:default_options].is_a?(Hash)
74
+
75
+ object.attributes.each do |attribute, value|
76
+ params.merge!({"#{self.config[:post_body_wrapper]}[#{attribute}]" => value})
77
+ end
78
+
79
+ params
80
+ else
81
+ object.attributes
82
+ end
83
+ end
84
+
85
+ def build_uri(object, method)
86
+ url = [self.config[:host].gsub(%r{/$}, ''), self.config[:service]]
87
+ url << object.send(self.config[:primary_key]) unless object.new_record?
88
+ uri = URI.parse "#{url.join('/')}#{self.config[:format]}"
89
+
90
+ # TRP: method DELETE has no POST body so we have to append any default options onto the query string
91
+ if method == :delete
92
+ uri.query = (self.config[:default_options] || {}).collect { |key, value| "#{key}=#{value}" }.join('&')
93
+ end
94
+
95
+ uri
96
+ end
97
+
98
+ end
99
+ end
@@ -0,0 +1,330 @@
1
+ module Perry::Association; end
2
+
3
+ module Perry::Association
4
+
5
+ ##
6
+ # Association::Base
7
+ #
8
+ # This is the base class for all associations. It defines the basic structure
9
+ # of an association. The basic nomenclature is as follows:
10
+ #
11
+ # TODO: Clean up this nomenclature. Source and Target should be switched. From
12
+ # the configuration side this is already done but the internal naming is backwards.
13
+ #
14
+ # Source: The start point of the association. The source class is the class
15
+ # on which the association is defined.
16
+ #
17
+ # Proxy: On a through association the proxy is the class on which the target
18
+ # association lives
19
+ #
20
+ # Target: The class that will ultimately be returned by the association
21
+ #
22
+ class Base
23
+ attr_accessor :source_klass, :id, :options
24
+
25
+ def initialize(klass, id, options={})
26
+ self.source_klass = klass
27
+ self.id = id.to_sym
28
+ self.options = options
29
+ end
30
+
31
+ def type
32
+ raise NotImplementedError, "You must define the type in subclasses."
33
+ end
34
+
35
+ def polymorphic?
36
+ raise(
37
+ NotImplementedError,
38
+ "You must define how your association is polymorphic in subclasses."
39
+ )
40
+ end
41
+
42
+ def collection?
43
+ raise NotImplementedError, "You must define collection? in subclasses."
44
+ end
45
+
46
+ def primary_key
47
+ options[:primary_key] || :id
48
+ end
49
+
50
+ def foreign_key
51
+ options[:foreign_key]
52
+ end
53
+
54
+ def target_klass(object=nil)
55
+ if options[:polymorphic] && object
56
+ poly_type = object.is_a?(Perry::Base) ? object.send("#{id}_type") : object
57
+ end
58
+
59
+ klass = if poly_type
60
+ type_string = [
61
+ options[:polymorphic_namespace],
62
+ sanitize_type_attribute(poly_type)
63
+ ].compact.join('::')
64
+ begin
65
+ eval(type_string)
66
+ rescue NameError => err
67
+ raise(
68
+ Perry::PolymorphicAssociationTypeError,
69
+ "No constant defined called #{type_string}"
70
+ )
71
+ end
72
+ else
73
+ unless options[:class_name]
74
+ raise(ArgumentError,
75
+ ":class_name option required for association declaration.")
76
+ end
77
+
78
+ unless options[:class_name] =~ /^::/
79
+ options[:class_name] = "::#{options[:class_name]}"
80
+ end
81
+
82
+ eval(options[:class_name])
83
+ end
84
+
85
+ Perry::Base.resolve_leaf_klass klass
86
+ end
87
+
88
+ def scope(object)
89
+ raise NotImplementedError, "You must define scope in subclasses"
90
+ end
91
+
92
+ # TRP: Only eager loadable if association query does not depend on instance
93
+ # data
94
+ def eager_loadable?
95
+ Perry::Relation::FINDER_OPTIONS.inject(true) do |condition, key|
96
+ condition && !options[key].respond_to?(:call)
97
+ end
98
+ end
99
+
100
+ protected
101
+
102
+ def base_scope(object)
103
+ target_klass(object).scoped.apply_finder_options base_finder_options(object)
104
+ end
105
+
106
+ def base_finder_options(object)
107
+ Perry::Relation::FINDER_OPTIONS.inject({}) do |sum, key|
108
+ value = self.options[key]
109
+ sum.merge!(key => value.respond_to?(:call) ? value.call(object) : value) if value
110
+ sum
111
+ end
112
+ end
113
+
114
+ # TRP: Make sure the value looks like a variable syntaxtually
115
+ def sanitize_type_attribute(string)
116
+ string.gsub(/[^a-zA-Z]\w*/, '')
117
+ end
118
+
119
+ end
120
+
121
+
122
+ class BelongsTo < Base
123
+
124
+ def type
125
+ :belongs_to
126
+ end
127
+
128
+ def collection?
129
+ false
130
+ end
131
+
132
+ def foreign_key
133
+ super || "#{id}_id".to_sym
134
+ end
135
+
136
+ def polymorphic?
137
+ !!options[:polymorphic]
138
+ end
139
+
140
+ def polymorphic_type
141
+ "#{id}_type".to_sym
142
+ end
143
+
144
+ ##
145
+ # Returns a scope on the target containing this association
146
+ #
147
+ # Builds conditions on top of the base_scope generated from any finder
148
+ # options set with the association
149
+ #
150
+ # belongs_to :foo, :foreign_key => :foo_id
151
+ #
152
+ # In addition to any finder options included with the association options
153
+ # the following scope will be added:
154
+ # where(:id => source[:foo_id])
155
+ #
156
+ def scope(object)
157
+ if object[self.foreign_key]
158
+ base_scope(object).where(self.primary_key => object[self.foreign_key])
159
+ end
160
+ end
161
+
162
+ end
163
+
164
+
165
+ class Has < Base
166
+
167
+ def foreign_key
168
+ super || if self.polymorphic?
169
+ "#{options[:as]}_id"
170
+ else
171
+ "#{Perry::Base.base_class_name(source_klass).downcase}_id"
172
+ end.to_sym
173
+ end
174
+
175
+
176
+ ##
177
+ # Returns a scope on the target containing this association
178
+ #
179
+ # Builds conditions on top of the base_scope generated from any finder
180
+ # options set with the association
181
+ #
182
+ # has_many :widgets, :class_name => "Widget", :foreign_key => :widget_id
183
+ # has_many :comments, :as => :parent
184
+ #
185
+ # In addition to any finder options included with the association options
186
+ # the following will be added:
187
+ #
188
+ # where(widget_id => source[:id])
189
+ #
190
+ # Or for the polymorphic :comments association:
191
+ #
192
+ # where(:parent_id => source[:id], :parent_type => source.class)
193
+ #
194
+ def scope(object)
195
+ return nil unless object[self.primary_key]
196
+ s = base_scope(object).where(self.foreign_key => object[self.primary_key])
197
+ if polymorphic?
198
+ s = s.where(
199
+ polymorphic_type => Perry::Base.base_class_name(object.class) )
200
+ end
201
+ s
202
+ end
203
+
204
+ def polymorphic_type
205
+ :"#{options[:as]}_type"
206
+ end
207
+
208
+ def polymorphic?
209
+ !!options[:as]
210
+ end
211
+
212
+ end
213
+
214
+
215
+ class HasMany < Has
216
+
217
+ def collection?
218
+ true
219
+ end
220
+
221
+ def type
222
+ :has_many
223
+ end
224
+
225
+ end
226
+
227
+
228
+ class HasOne < Has
229
+
230
+ def collection?
231
+ false
232
+ end
233
+
234
+ def type
235
+ :has_one
236
+ end
237
+
238
+ end
239
+
240
+
241
+ class HasManyThrough < HasMany
242
+ attr_accessor :proxy_association
243
+ attr_accessor :target_association
244
+
245
+ def proxy_association
246
+ @proxy_association ||= source_klass.defined_associations[options[:through]] ||
247
+ raise(
248
+ Perry::AssociationNotFound,
249
+ ":has_many_through: '#{options[:through]}' is not an association " +
250
+ "on #{source_klass}"
251
+ )
252
+ end
253
+
254
+ def target_association
255
+ return @target_association if @target_association
256
+
257
+ klass = proxy_association.target_klass
258
+ @target_association = klass.defined_associations[self.id] ||
259
+ klass.defined_associations[self.options[:source]] ||
260
+ raise(Perry::AssociationNotFound,
261
+ ":has_many_through: '#{options[:source] || self.id}' is not an " +
262
+ "association on #{klass}"
263
+ )
264
+ end
265
+
266
+ def scope(object)
267
+ # Which attribute's values should be used on the proxy
268
+ key = if target_is_has?
269
+ target_association.primary_key.to_sym
270
+ else
271
+ target_association.foreign_key.to_sym
272
+ end
273
+
274
+ # Fetch the ids of all records on the proxy using the correct key
275
+ proxy_ids = proc do
276
+ proxy_association.scope(object).select(key).collect(&key)
277
+ end
278
+
279
+ # Use these ids to build a scope on the target object
280
+ relation = target_klass.scoped
281
+
282
+ if target_is_has?
283
+ relation = relation.where(
284
+ proc do
285
+ { target_association.foreign_key => proxy_ids.call }
286
+ end
287
+ )
288
+ else
289
+ relation = relation.where(
290
+ proc do
291
+ { target_association.primary_key => proxy_ids.call }
292
+ end
293
+ )
294
+ end
295
+
296
+ # Add polymorphic type condition if target is polymorphic and has
297
+ if target_association.polymorphic? && target_is_has?
298
+ relation = relation.where(
299
+ target_association.polymorphic_type =>
300
+ Perry::Base.base_class_name(proxy_association.target_klass(object))
301
+ )
302
+ end
303
+
304
+ relation
305
+ end
306
+
307
+ def target_klass
308
+ target_association.target_klass(options[:source_type])
309
+ end
310
+
311
+ def target_type
312
+ target_association.type
313
+ end
314
+
315
+ def target_is_has?
316
+ target_association.is_a?(Has)
317
+ end
318
+
319
+ def type
320
+ :has_many_through
321
+ end
322
+
323
+ def polymorphic?
324
+ false
325
+ end
326
+
327
+ end
328
+
329
+
330
+ end