ar-extensions 0.5.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,474 @@
1
+ require 'forwardable'
2
+
3
+ # ActiveRecord::Extensions provides additional functionality to the ActiveRecord
4
+ # ORM library created by DHH for Rails.
5
+ #
6
+ # It's main features include:
7
+ # * better finder support using a :conditions Hash for ActiveRecord::Base#find
8
+ # * better finder support using any object that responds to the to_sql method
9
+ # * mass data import functionality
10
+ # * a more modular design to extending ActiveRecord
11
+ #
12
+ #
13
+ # == Using Better Finder Hash Support
14
+ # Here are a few examples, please refer to the class documentation for each
15
+ # extensions:
16
+ #
17
+ # class Post < ActiveRecord::Base ; end
18
+ #
19
+ # Post.find( :all, :conditions=>{
20
+ # :title => "Title", # title='Title'
21
+ # :author_contains => "Zach", # author like '%Zach%'
22
+ # :author_starts_with => "Zach", # author like 'Zach%'
23
+ # :author_ends_with => "Dennis", # author like '%Zach'
24
+ # :published_at => (Date.now-30 .. Date.now), # published_at BETWEEN xxx AND xxx
25
+ # :rating => [ 4, 5, 6 ], # rating IN ( 4, 5, 6 )
26
+ # :rating_not_in => [ 7, 8, 9 ] # rating NOT IN( 4, 5, 6 )
27
+ # :rating_ne => 4, # rating != 4
28
+ # :rating_gt => 4, # rating > 4
29
+ # :rating_lt => 4, # rating < 4
30
+ # :content => /(a|b|c)/ # REGEXP '(a|b|c)'
31
+ # )
32
+ #
33
+ #
34
+ # == Create Your Own Finder Extension Example
35
+ # The following example shows you how-to create a robust and reliable
36
+ # finder extension which allows you to use Ranges in your :conditions Hash. This
37
+ # is the actual implementation in ActiveRecord::Extensions.
38
+ #
39
+ # class RangeExt
40
+ # NOT_IN_RGX = /^(.+)_(ne|not|not_in|not_between)$/
41
+ #
42
+ # def self.process( key, val, caller )
43
+ # return nil unless val.is_a?( Range )
44
+ # match_data = key.to_s.match( NOT_IN_RGX )
45
+ # key = match_data.captures[0] if match_data
46
+ # fieldname = caller.connection.quote_column_name( key )
47
+ # min = caller.connection.quote( val.first, caller.columns_hash[ key ] )
48
+ # max = caller.connection.quote( val.last, caller.columns_hash[ key ] )
49
+ # str = "#{caller.table_name}.#{fieldname} #{match_data ? 'NOT ' : '' } BETWEEN #{min} AND #{max}"
50
+ # Result.new( str, nil )
51
+ # end
52
+ #
53
+ #
54
+ # == Using to_sql Ducks In Your Find Methods!
55
+ # The below example shows you how-to utilize objects that respond_to the method +to_sql+ in
56
+ # your finds:
57
+ #
58
+ # class InsuranceClaim < ActiveRecord::Base ; end
59
+ #
60
+ # class InsuranceClaimAgeAndTypeQuery
61
+ # def to_sql
62
+ # "age_in_days BETWEEN 1 AND 60 AND claim_type IN( 'typea', 'typeb' )"
63
+ # end
64
+ # end
65
+ #
66
+ # claims = InsuranceClaim.find( :all, InsuranceClaimAgeAndTypeQuery.new )
67
+ #
68
+ # claims = InsuranceClaim.find( :all, :conditions=>{
69
+ # :claim_amount_gt => 30000,
70
+ # :age_and_type => InsuranceClaimAgeAndTypeQuery.new }
71
+ # )
72
+ #
73
+ # == Importing Lots of Data
74
+ #
75
+ # ActiveRecord executes a single INSERT statement for every cal to 'create'
76
+ # and for every call to 'save' on a new model object. When you have only
77
+ # a handful of records to create or save this is not a big deal, but when
78
+ # you have hundreds, thousands or hundreds of thousands of records
79
+ # you need to have better performance.
80
+ #
81
+ # Below is an example of how to import the least amount of INSERT statements
82
+ # using mechanisms provided by your database vendor:
83
+ #
84
+ # class Student < ActiveRecord::Base ; end
85
+ #
86
+ # column_names = Student.columns.map{ |column| column.name }
87
+ # value_sets = some_method_to_load_data_from_csv_file( 'students.csv' )
88
+ # options = { :valudate => true }
89
+ #
90
+ # Student.import( column_names, value_sets, options )
91
+ #
92
+ # The +import+ functionality can be used even if there is not specific
93
+ # support for you vendor. This happens when a particular database vendor
94
+ # specific enhancement hasn't been added to ActiveRecord::Extensions.
95
+ # You can still use +import+ though because the +import+ functionality has
96
+ # been created with backwards compatibility. You may still get better
97
+ # performance using +import+, but you will definitely get no worse then
98
+ # ActiveRecord's create or save methods.
99
+ #
100
+ # See ActiveRecord::Base.import for more information and other ways to use
101
+ # this functionality.
102
+ #
103
+ # == Developers
104
+ # * Zach Dennis
105
+ # * Mark Van Holsytn
106
+ #
107
+ # == Homepage
108
+ # * Project Site: http://www.continuousthinking.com/tags/arext
109
+ # * Rubyforge Project: http://rubyforge.org/projects/arext
110
+ # * Anonymous SVN: svn checkout svn://rubyforge.org/var/svn/arext
111
+ #
112
+ module ActiveRecord::Extensions
113
+
114
+ Result = Struct.new( :sql, :value )
115
+
116
+ # ActiveRecored::Extensions::Registry is used to register finder extensions.
117
+ # Extensions are processed in last in first out order, like a stack.
118
+ class Registry # :nodoc:
119
+
120
+ def options( extension )
121
+ extension_arr = @registry.detect{ |arr| arr.first == extension }
122
+ return unless extension_arr
123
+ extension_arr.last
124
+ end
125
+
126
+ def registers?( extension ) # :nodoc:
127
+ @registry.detect{ |arr| arr.first == extension }
128
+ end
129
+
130
+ def register( extension, options ) # :nodoc:
131
+ @registry << [ extension, options ]
132
+ end
133
+
134
+ def initialize # :nodoc:
135
+ @registry = []
136
+ end
137
+
138
+ def process( field, value, caller ) # :nodoc:
139
+ current_adapter = caller.connection.adapter_name.downcase
140
+ @registry.reverse.each do |(extension,options)|
141
+ adapters = options[:adapters]
142
+ adapters.map!{ |e| e.to_s } unless adapters == :all
143
+ next if options[:adapters] != :all and adapters.grep( /#{current_adapter}/ ).empty?
144
+ if result=extension.process( field, value, caller )
145
+ return result
146
+ end
147
+ end
148
+ nil
149
+ end
150
+
151
+ end
152
+
153
+ class << self
154
+ extend Forwardable
155
+
156
+ def register( extension, options ) # :nodoc:
157
+ @registry ||= Registry.new
158
+ @registry.register( extension, options )
159
+ end
160
+
161
+ def_delegator :@registry, :process, :process
162
+ end
163
+
164
+
165
+ # ActiveRecord::Extension to translate an Array of values
166
+ # into the approriate IN( ... ) or NOT IN( ... ) SQL.
167
+ #
168
+ # == Examples
169
+ # Model.find :all, :conditions=>{ :id => [ 1,2,3 ] }
170
+ #
171
+ # # the following three calls are equivalent
172
+ # Model.find :all, :conditions=>{ :id_ne => [ 4,5,6 ] }
173
+ # Model.find :all, :conditions=>{ :id_not => [ 4,5,6 ] }
174
+ # Model.find :all, :conditions=>{ :id_not_in => [ 4,5,6 ] }
175
+ class ArrayExt
176
+
177
+ NOT_EQUAL_RGX = /(.+)_(ne|not|not_in)/
178
+
179
+ def self.process( key, val, caller )
180
+ if val.is_a?( Array )
181
+ match_data = key.to_s.match( NOT_EQUAL_RGX )
182
+ key = match_data.captures[0] if match_data
183
+ str = "#{caller.table_name}.#{caller.connection.quote_column_name( key )} " +
184
+ (match_data ? 'NOT ' : '') + "IN( ? )"
185
+ return Result.new( str, val )
186
+ end
187
+ nil
188
+ end
189
+
190
+ end
191
+
192
+
193
+ # ActiveRecord::Extension to translate Hash keys which end in
194
+ # +_lt+, +_lte+, +_gt+, or +_gte+ with the approriate <, <=, >,
195
+ # or >= symbols.
196
+ # * +_lt+ - denotes less than
197
+ # * +_gt+ - denotes greater than
198
+ # * +_lte+ - denotes less than or equal to
199
+ # * +_gte+ - denotes greater than or equal to
200
+ #
201
+ # == Examples
202
+ # Model.find :all, :conditions=>{ 'number_gt'=>100 }
203
+ # Model.find :all, :conditions=>{ 'number_lt'=>100 }
204
+ # Model.find :all, :conditions=>{ 'number_gte'=>100 }
205
+ # Model.find :all, :conditions=>{ 'number_lte'=>100 }
206
+ class Comparison
207
+
208
+ SUFFIX_MAP = { 'eq'=>'=', 'lt'=>'<', 'lte'=>'<=', 'gt'=>'>', 'gte'=>'>=', 'ne'=>'!=', 'not'=>'!=' }
209
+
210
+ def self.process( key, val, caller )
211
+ process_without_suffix( key, val, caller ) || process_with_suffix( key, val, caller )
212
+ end
213
+
214
+ def self.process_without_suffix( key, val, caller )
215
+ return nil unless caller.columns_hash.has_key?( key )
216
+ if val.nil?
217
+ str = "#{caller.table_name}.#{caller.connection.quote_column_name( key )} IS NULL"
218
+ else
219
+ str = "#{caller.table_name}.#{caller.connection.quote_column_name( key )}=" +
220
+ "#{caller.connection.quote( val, caller.columns_hash[ key ] )} "
221
+ end
222
+ Result.new( str, nil )
223
+ end
224
+
225
+ def self.process_with_suffix( key, val, caller )
226
+ return nil unless val.is_a?( String ) or val.is_a?( Numeric )
227
+ SUFFIX_MAP.each_pair do |k,v|
228
+ match_data = key.to_s.match( /(.+)_#{k}$/ )
229
+ if match_data
230
+ fieldname = match_data.captures[0]
231
+ return nil unless caller.columns_hash.has_key?( fieldname )
232
+ str = "#{caller.table_name}.#{caller.connection.quote_column_name( fieldname )} " +
233
+ "#{v} #{caller.connection.quote( val, caller.columns_hash[ fieldname ] )} "
234
+ return Result.new( str, nil )
235
+ end
236
+ end
237
+ nil
238
+ end
239
+
240
+ end
241
+
242
+
243
+ # ActiveRecord::Extension to translate Hash keys which end in
244
+ # +_like+ or +_contains+ with the approriate LIKE keyword
245
+ # used in SQL.
246
+ #
247
+ # == Examples
248
+ # # the below two examples are equivalent
249
+ # Model.find :all, :conditions=>{ :name_like => 'John' }
250
+ # Model.find :all, :conditions=>{ :name_contains => 'John' }
251
+ #
252
+ # Model.find :all, :conditions=>{ :name_starts_with => 'J' }
253
+ # Model.find :all, :conditions=>{ :name_ends_with => 'n' }
254
+ class Like
255
+ LIKE_RGX = /(.+)_(like|contains)$/
256
+ STARTS_WITH_RGX = /(.+)_starts_with$/
257
+ ENDS_WITH_RGX = /(.+)_ends_with$/
258
+
259
+ def self.process( key, val, caller )
260
+ if match_data=key.to_s.match( LIKE_RGX )
261
+ fieldname = match_data.captures[0]
262
+ str = "#{caller.table_name}.#{caller.connection.quote_column_name( fieldname )} LIKE ?"
263
+ return Result.new( str, "%#{val}%" )
264
+ elsif match_data=key.to_s.match( STARTS_WITH_RGX )
265
+ fieldname = match_data.captures[0]
266
+ str = "#{caller.table_name}.#{caller.connection.quote_column_name( fieldname )} LIKE ?"
267
+ return Result.new( str, "#{val}%" )
268
+ elsif match_data=key.to_s.match( ENDS_WITH_RGX )
269
+ fieldname = match_data.captures[0]
270
+ str = "#{caller.table_name}.#{caller.connection.quote_column_name( fieldname )} LIKE ?"
271
+ return Result.new( str, "%#{val}" )
272
+ end
273
+ nil
274
+ end
275
+
276
+ end
277
+
278
+
279
+ # ActiveRecord::Extension to translate a ruby Range object into SQL's BETWEEN ... AND ...
280
+ # or NOT BETWEEN ... AND ... . This works on Ranges of Numbers, Dates, Times, etc.
281
+ #
282
+ # == Examples
283
+ # # the following two statements are identical because of how Ranges treat .. and ...
284
+ # Model.find :all, :conditions=>{ :id => ( 1 .. 2 ) }
285
+ # Model.find :all, :conditions=>{ :id => ( 1 ... 2 ) }
286
+ #
287
+ # # the following four statements are identical, this finds NOT BETWEEN matches
288
+ # Model.find :all, :conditions=>{ :id_ne => ( 4 .. 6 ) }
289
+ # Model.find :all, :conditions=>{ :id_not => ( 4 .. 6 ) }
290
+ # Model.find :all, :conditions=>{ :id_not_in => ( 4 ..6 ) }
291
+ # Model.find :all, :conditions=>{ :id_not_between => ( 4 .. 6 ) }
292
+ #
293
+ # # a little more creative, working with date ranges
294
+ # Model.find :all, :conditions=>{ :created_on => (Date.now-30 .. Date.now) }
295
+ class RangeExt
296
+ NOT_IN_RGX = /^(.+)_(ne|not|not_in|not_between)$/
297
+
298
+ def self.process( key, val, caller )
299
+ if val.is_a?( Range )
300
+ match_data = key.to_s.match( NOT_IN_RGX )
301
+ key = match_data.captures[0] if match_data
302
+ fieldname = caller.connection.quote_column_name( key )
303
+ min = caller.connection.quote( val.first, caller.columns_hash[ key ] )
304
+ max = caller.connection.quote( val.last, caller.columns_hash[ key ] )
305
+ str = "#{caller.table_name}.#{fieldname} #{match_data ? 'NOT ' : '' } BETWEEN #{min} AND #{max}"
306
+ return Result.new( str, nil )
307
+ end
308
+ nil
309
+ end
310
+
311
+ end
312
+
313
+ # A base class for database vendor specific Regexp implementations. This is meant to be
314
+ # subclassed only because of the helper method(s) it provides.
315
+ class RegexpBase
316
+
317
+ NOT_EQUAL_RGX = /^(.+)_(ne|not|does_not_match)$/
318
+
319
+ # A result class which provides an easy interface.
320
+ class RegexpResult
321
+ attr_reader :fieldname, :negate
322
+
323
+ def initialize( fieldname, negate=false )
324
+ @fieldname, @negate = fieldname, negate
325
+ end
326
+
327
+ def negate?
328
+ negate ? true : false
329
+ end
330
+ end
331
+
332
+ # Given the passed in +str+ and +caller+ this will return a RegexpResult object
333
+ # which gives the database quoted fieldname/column and can tell you whether or not
334
+ # the original +str+ is indicating a negated regular expression.
335
+ #
336
+ # == Examples
337
+ # r = RegexpBase.field_result( 'id' )
338
+ # r.fieldname => # 'id'
339
+ # r.negate? => # false
340
+ #
341
+ # r = RegexpBase.field_result( 'id_ne' )
342
+ # r.fieldname => # 'id'
343
+ # r.negate? => # true
344
+ #
345
+ # r = RegexpBase.field_result( 'id_not' )
346
+ # r.fieldname => # 'id'
347
+ # r.negate? => # true
348
+ #
349
+ # r = RegexpBase.field_result( 'id_does_not_match' )
350
+ # r.fieldname => # 'id'
351
+ # r.negate? => # true
352
+ def self.field_result( str, caller )
353
+ negate = false
354
+ if match_data=str.to_s.match( NOT_EQUAL_RGX )
355
+ negate = true
356
+ str = match_data.captures[0]
357
+ end
358
+ fieldname = caller.connection.quote_column_name( str )
359
+ RegexpResult.new( fieldname, negate )
360
+ end
361
+
362
+ end
363
+
364
+
365
+ # ActiveRecord::Extension for implementing Regexp implementation for MySQL.
366
+ # See documention for RegexpBase.
367
+ class MySQLRegexp < RegexpBase
368
+
369
+ def self.process( key, val, caller )
370
+ return nil unless val.is_a?( Regexp )
371
+ r = field_result( key, caller )
372
+ Result.new( "#{caller.table_name}.#{r.fieldname} #{r.negate? ? 'NOT ':''} REGEXP ?", val )
373
+ end
374
+
375
+ end
376
+
377
+
378
+ # ActiveRecord::Extension for implementing Regexp implementation for PostgreSQL.
379
+ # See documention for RegexpBase.
380
+ #
381
+ # Note: this doesn't support case insensitive matches.
382
+ class PostgreSQLRegexp < RegexpBase
383
+
384
+ def self.process( key, val, caller )
385
+ return nil unless val.is_a?( Regexp )
386
+ r = field_result( key, caller )
387
+ return Result.new( "#{caller.table_name}.#{r.fieldname} #{r.negate? ? '!~ ':'~'} ?", val )
388
+ end
389
+
390
+ end
391
+
392
+
393
+ # ActiveRecord::Extension for implementing Regexp implementation for MySQL.
394
+ # See documention for RegexpBase.
395
+ class SqliteRegexp < RegexpBase
396
+ class_inheritable_accessor :connections
397
+ self.connections = []
398
+
399
+ def self.add_rlike_function( connection )
400
+ self.connections << connection
401
+ unless connection.respond_to?( 'sqlite_regexp_support?' )
402
+ class << connection
403
+ def sqlite_regexp_support? ; true ; end
404
+ end
405
+ connection.instance_eval( '@connection' ).create_function( 'rlike', 3 ) do |func, a, b, negate|
406
+ if negate =~ /true/
407
+ func.set_result 1 if a.to_s !~ /#{b}/
408
+ else
409
+ func.set_result 1 if a.to_s =~ /#{b}/
410
+ end
411
+ end
412
+ end
413
+ end
414
+
415
+ def self.process( key, val, caller )
416
+ return nil unless val.is_a?( Regexp )
417
+ r = field_result( key, caller )
418
+ unless self.connections.include?( caller.connection )
419
+ add_rlike_function( caller.connection )
420
+ end
421
+ Result.new( "rlike( #{r.fieldname}, ?, '#{r.negate?}' )", val )
422
+ end
423
+
424
+ end
425
+
426
+ class DatetimeSupport
427
+ SUFFIX_MAP = { 'eq'=>'=', 'lt'=>'<', 'lte'=>'<=', 'gt'=>'>', 'gte'=>'>=', 'ne'=>'!=', 'not'=>'!=' }
428
+
429
+ def self.process( key, val, caller )
430
+ return unless val.is_a?( Time )
431
+ process_without_suffix( key, val, caller ) || process_with_suffix( key, val, caller )
432
+ end
433
+
434
+ def self.process_without_suffix( key, val, caller )
435
+ return nil unless caller.columns_hash.has_key?( key )
436
+ if val.nil?
437
+ str = "#{caller.table_name}.#{caller.connection.quote_column_name( key )} IS NULL"
438
+ else
439
+ str = "#{caller.table_name}.#{caller.connection.quote_column_name( key )}=" +
440
+ "#{caller.connection.quote( val.to_s(:db), caller.columns_hash[ key ] )} "
441
+ end
442
+ Result.new( str, nil )
443
+ end
444
+
445
+ def self.process_with_suffix( key, val, caller )
446
+ SUFFIX_MAP.each_pair do |k,v|
447
+ match_data = key.to_s.match( /(.+)_#{k}$/ )
448
+ if match_data
449
+ fieldname = match_data.captures[0]
450
+ return nil unless caller.columns_hash.has_key?( fieldname )
451
+ str = "#{caller.table_name}.#{caller.connection.quote_column_name( fieldname )} " +
452
+ "#{v} #{caller.connection.quote( val.to_s(:db), caller.columns_hash[ fieldname ] )} "
453
+ return Result.new( str, nil )
454
+ end
455
+ end
456
+ nil
457
+ end
458
+
459
+
460
+ end
461
+
462
+
463
+ register Comparison, :adapters=>:all
464
+ register Like, :adapters=>:all
465
+ register ArrayExt, :adapters=>:all
466
+ register RangeExt, :adapters=>:all
467
+ register MySQLRegexp, :adapters=>[ :mysql ]
468
+ register PostgreSQLRegexp, :adapters=>[ :postgresql ]
469
+ register SqliteRegexp, :adapters =>[ :sqlite ]
470
+ register DatetimeSupport, :adapters =>[ :mysql, :sqlite ]
471
+ end
472
+
473
+
474
+