perry 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
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