ar-extensions 0.5.1

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.
@@ -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
+