dmarkov-right_aws 1.10.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.
@@ -0,0 +1,930 @@
1
+ #
2
+ # Copyright (c) 2008 RightScale Inc
3
+ #
4
+ # Permission is hereby granted, free of charge, to any person obtaining
5
+ # a copy of this software and associated documentation files (the
6
+ # "Software"), to deal in the Software without restriction, including
7
+ # without limitation the rights to use, copy, modify, merge, publish,
8
+ # distribute, sublicense, and/or sell copies of the Software, and to
9
+ # permit persons to whom the Software is furnished to do so, subject to
10
+ # the following conditions:
11
+ #
12
+ # The above copyright notice and this permission notice shall be
13
+ # included in all copies or substantial portions of the Software.
14
+ #
15
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
16
+ # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
17
+ # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
18
+ # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
19
+ # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
20
+ # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
21
+ # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
22
+ #
23
+
24
+ begin
25
+ require 'uuidtools'
26
+ rescue LoadError => e
27
+ STDERR.puts("RightSDB requires the uuidtools gem. Run \'gem install uuidtools\' and try again.")
28
+ exit
29
+ end
30
+
31
+ module RightAws
32
+
33
+ # = RightAws::ActiveSdb -- RightScale SDB interface (alpha release)
34
+ # The RightAws::ActiveSdb class provides a complete interface to Amazon's Simple
35
+ # Database Service.
36
+ #
37
+ # ActiveSdb is in alpha and does not load by default with the rest of RightAws. You must use an additional require statement to load the ActiveSdb class. For example:
38
+ #
39
+ # require 'right_aws'
40
+ # require 'sdb/active_sdb'
41
+ #
42
+ # Additionally, the ActiveSdb class requires the 'uuidtools' gem; this gem is not normally required by RightAws and is not installed as a
43
+ # dependency of RightAws.
44
+ #
45
+ # Simple ActiveSdb usage example:
46
+ #
47
+ # class Client < RightAws::ActiveSdb::Base
48
+ # end
49
+ #
50
+ # # connect to SDB
51
+ # RightAws::ActiveSdb.establish_connection
52
+ #
53
+ # # create domain
54
+ # Client.create_domain
55
+ #
56
+ # # create initial DB
57
+ # Client.create 'name' => 'Bush', 'country' => 'USA', 'gender' => 'male', 'expiration' => '2009', 'post' => 'president'
58
+ # Client.create 'name' => 'Putin', 'country' => 'Russia', 'gender' => 'male', 'expiration' => '2008', 'post' => 'president'
59
+ # Client.create 'name' => 'Medvedev', 'country' => 'Russia', 'gender' => 'male', 'expiration' => '2012', 'post' => 'president'
60
+ # Client.create 'name' => 'Mary', 'country' => 'USA', 'gender' => 'female', 'hobby' => ['patchwork', 'bundle jumping']
61
+ # Client.create 'name' => 'Mary', 'country' => 'Russia', 'gender' => 'female', 'hobby' => ['flowers', 'cats', 'cooking']
62
+ # sandy_id = Client.create('name' => 'Sandy', 'country' => 'Russia', 'gender' => 'female', 'hobby' => ['flowers', 'cats', 'cooking']).id
63
+ #
64
+ # # find all Bushes in USA
65
+ # Client.find(:all, :conditions => ["['name'=?] intersection ['country'=?]",'Bush','USA']).each do |client|
66
+ # client.reload
67
+ # puts client.attributes.inspect
68
+ # end
69
+ #
70
+ # # find all Maries through the world
71
+ # Client.find_all_by_name_and_gender('Mary','female').each do |mary|
72
+ # mary.reload
73
+ # puts "#{mary[:name]}, gender: #{mary[:gender]}, hobbies: #{mary[:hobby].join(',')}"
74
+ # end
75
+ #
76
+ # # find new russian president
77
+ # medvedev = Client.find_by_post_and_country_and_expiration('president','Russia','2012')
78
+ # if medvedev
79
+ # medvedev.reload
80
+ # medvedev.save_attributes('age' => '42', 'hobby' => 'Gazprom')
81
+ # end
82
+ #
83
+ # # retire old president
84
+ # Client.find_by_name('Putin').delete
85
+ #
86
+ # # Sandy disappointed in 'cooking' and decided to hide her 'gender' and 'country' ()
87
+ # sandy = Client.find(sandy_id)
88
+ # sandy.reload
89
+ # sandy.delete_values('hobby' => ['cooking'] )
90
+ # sandy.delete_attributes('country', 'gender')
91
+ #
92
+ # # remove domain
93
+ # Client.delete_domain
94
+ #
95
+ class ActiveSdb
96
+
97
+ module ActiveSdbConnect
98
+ def connection
99
+ @connection || raise(ActiveSdbError.new('Connection to SDB is not established'))
100
+ end
101
+ # Create a new handle to an Sdb account. All handles share the same per process or per thread
102
+ # HTTP connection to Amazon Sdb. Each handle is for a specific account.
103
+ # The +params+ are passed through as-is to RightAws::SdbInterface.new
104
+ # Params:
105
+ # { :server => 'sdb.amazonaws.com' # Amazon service host: 'sdb.amazonaws.com'(default)
106
+ # :port => 443 # Amazon service port: 80 or 443(default)
107
+ # :protocol => 'https' # Amazon service protocol: 'http' or 'https'(default)
108
+ # :signature_version => '0' # The signature version : '0' or '1'(default)
109
+ # :multi_thread => true|false # Multi-threaded (connection per each thread): true or false(default)
110
+ # :logger => Logger Object # Logger instance: logs to STDOUT if omitted
111
+ # :nil_representation => 'mynil'} # interpret Ruby nil as this string value; i.e. use this string in SDB to represent Ruby nils (default is the string 'nil')
112
+
113
+ def establish_connection(aws_access_key_id=nil, aws_secret_access_key=nil, params={})
114
+ @connection = RightAws::SdbInterface.new(aws_access_key_id, aws_secret_access_key, params)
115
+ end
116
+ end
117
+
118
+ class ActiveSdbError < RuntimeError ; end
119
+
120
+ class << self
121
+ include ActiveSdbConnect
122
+
123
+ # Retreive a list of domains.
124
+ #
125
+ # put RightAws::ActiveSdb.domains #=> ['co-workers','family','friends','clients']
126
+ #
127
+ def domains
128
+ connection.list_domains[:domains]
129
+ end
130
+
131
+ # Create new domain.
132
+ # Raises no errors if the domain already exists.
133
+ #
134
+ # RightAws::ActiveSdb.create_domain('alpha') #=> {:request_id=>"6fc652a0-0000-41d5-91f4-3ed390a3d3b2", :box_usage=>"0.0055590278"}
135
+ #
136
+ def create_domain(domain_name)
137
+ connection.create_domain(domain_name)
138
+ end
139
+
140
+ # Remove domain from SDB.
141
+ # Raises no errors if the domain does not exist.
142
+ #
143
+ # RightAws::ActiveSdb.create_domain('alpha') #=> {:request_id=>"6fc652a0-0000-41c6-91f4-3ed390a3d3b2", :box_usage=>"0.0055590001"}
144
+ #
145
+ def delete_domain(domain_name)
146
+ connection.delete_domain(domain_name)
147
+ end
148
+ end
149
+
150
+ class Base
151
+
152
+ class << self
153
+ include ActiveSdbConnect
154
+
155
+ # next_token value returned by last find: is useful to continue finding
156
+ attr_accessor :next_token
157
+
158
+ # Returns a RightAws::SdbInterface object
159
+ #
160
+ # class A < RightAws::ActiveSdb::Base
161
+ # end
162
+ #
163
+ # class B < RightAws::ActiveSdb::Base
164
+ # end
165
+ #
166
+ # class C < RightAws::ActiveSdb::Base
167
+ # end
168
+ #
169
+ # RightAws::ActiveSdb.establish_connection 'key_id_1', 'secret_key_1'
170
+ #
171
+ # C.establish_connection 'key_id_2', 'secret_key_2'
172
+ #
173
+ # # A and B uses the default connection, C - uses its own
174
+ # puts A.connection #=> #<RightAws::SdbInterface:0xb76d6d7c>
175
+ # puts B.connection #=> #<RightAws::SdbInterface:0xb76d6d7c>
176
+ # puts C.connection #=> #<RightAws::SdbInterface:0xb76d6ca0>
177
+ #
178
+ def connection
179
+ @connection || ActiveSdb::connection
180
+ end
181
+
182
+ @domain = nil
183
+
184
+ # Current domain name.
185
+ #
186
+ # # if 'ActiveSupport' is not loaded then class name converted to downcase
187
+ # class Client < RightAws::ActiveSdb::Base
188
+ # end
189
+ # puts Client.domain #=> 'client'
190
+ #
191
+ # # if 'ActiveSupport' is loaded then class name being tableized
192
+ # require 'activesupport'
193
+ # class Client < RightAws::ActiveSdb::Base
194
+ # end
195
+ # puts Client.domain #=> 'clients'
196
+ #
197
+ # # Explicit domain name definition
198
+ # class Client < RightAws::ActiveSdb::Base
199
+ # set_domain_name :foreign_clients
200
+ # end
201
+ # puts Client.domain #=> 'foreign_clients'
202
+ #
203
+ def domain
204
+ unless @domain
205
+ if defined? ActiveSupport::CoreExtensions::String::Inflections
206
+ @domain = name.tableize
207
+ else
208
+ @domain = name.downcase
209
+ end
210
+ end
211
+ @domain
212
+ end
213
+
214
+ # Change the default domain name to user defined.
215
+ #
216
+ # class Client < RightAws::ActiveSdb::Base
217
+ # set_domain_name :foreign_clients
218
+ # end
219
+ #
220
+ def set_domain_name(domain)
221
+ @domain = domain.to_s
222
+ end
223
+
224
+ # Create domain at SDB.
225
+ # Raises no errors if the domain already exists.
226
+ #
227
+ # class Client < RightAws::ActiveSdb::Base
228
+ # end
229
+ # Client.create_domain #=> {:request_id=>"6fc652a0-0000-41d5-91f4-3ed390a3d3b2", :box_usage=>"0.0055590278"}
230
+ #
231
+ def create_domain
232
+ connection.create_domain(domain)
233
+ end
234
+
235
+ # Remove domain from SDB.
236
+ # Raises no errors if the domain does not exist.
237
+ #
238
+ # class Client < RightAws::ActiveSdb::Base
239
+ # end
240
+ # Client.delete_domain #=> {:request_id=>"e14d90d3-0000-4898-9995-0de28cdda270", :box_usage=>"0.0055590278"}
241
+ #
242
+ def delete_domain
243
+ connection.delete_domain(domain)
244
+ end
245
+
246
+ # Perform a find request.
247
+ #
248
+ # Single record:
249
+ #
250
+ # Client.find(:first)
251
+ # Client.find(:first, :conditions=> [ "['name'=?] intersection ['wife'=?]", "Jon", "Sandy"])
252
+ #
253
+ # Bunch of records:
254
+ #
255
+ # Client.find(:all)
256
+ # Client.find(:all, :limit => 10)
257
+ # Client.find(:all, :conditions=> [ "['name'=?] intersection ['girlfriend'=?]", "Jon", "Judy"])
258
+ # Client.find(:all, :conditions=> [ "['name'=?]", "Sandy"], :limit => 3)
259
+ #
260
+ # Records by ids:
261
+ #
262
+ # Client.find('1')
263
+ # Client.find('1234987b4583475347523948')
264
+ # Client.find('1','2','3','4', :conditions=> [ "['toys'=?]", "beer"])
265
+ #
266
+ # Find helpers: RightAws::ActiveSdb::Base.find_by_... and RightAws::ActiveSdb::Base.find_all_by_...
267
+ #
268
+ # Client.find_by_name('Matias Rust')
269
+ # Client.find_by_name_and_city('Putin','Moscow')
270
+ # Client.find_by_name_and_city_and_post('Medvedev','Moscow','president')
271
+ #
272
+ # Client.find_all_by_author('G.Bush jr')
273
+ # Client.find_all_by_age_and_gender_and_ethnicity('34','male','russian')
274
+ # Client.find_all_by_gender_and_country('male', 'Russia', :auto_load => true, :order => 'name desc')
275
+ #
276
+ # Returned records have to be +reloaded+ to access their attributes.
277
+ #
278
+ # item = Client.find_by_name('Cat') #=> #<Client:0xb77d0d40 @attributes={"id"=>"2937601a-e45d-11dc-a75f-001bfc466dd7"}, @new_record=false>
279
+ # item.reload #=> #<Client:0xb77d0d40 @attributes={"id"=>"2937601a-e45d-11dc-a75f-001bfc466dd7", "name"=>["Cat"], "toys"=>["Jons socks", "clew", "mice"]}, @new_record=false>
280
+ #
281
+ # Continue listing:
282
+ # # initial listing
283
+ # Client.find(:all, :limit => 10)
284
+ # # continue listing
285
+ # begin
286
+ # Client.find(:all, :limit => 10, :next_token => Client.next_token)
287
+ # end while Client.next_token
288
+ #
289
+ # Sort oder:
290
+ # Client.find(:all, :order => 'gender')
291
+ # Client.find(:all, :order => 'name desc')
292
+ #
293
+ # Attributes auto load (be carefull - this may take lot of time for a huge bunch of records):
294
+ # Client.find(:first) #=> #<Client:0xb77d0d40 @attributes={"id"=>"2937601a-e45d-11dc-a75f-001bfc466dd7"}, @new_record=false>
295
+ # Client.find(:first, :auto_load => true) #=> #<Client:0xb77d0d40 @attributes={"id"=>"2937601a-e45d-11dc-a75f-001bfc466dd7", "name"=>["Cat"], "toys"=>["Jons socks", "clew", "mice"]}, @new_record=false>
296
+ #
297
+ # see http://docs.amazonwebservices.com/AmazonSimpleDB/2007-11-07/DeveloperGuide/index.html?UsingQuery.html
298
+ #
299
+ def find(*args)
300
+ options = args.last.is_a?(Hash) ? args.pop : {}
301
+ case args.first
302
+ when :all then find_every options
303
+ when :first then find_initial options
304
+ else find_from_ids args, options
305
+ end
306
+ end
307
+
308
+ # Perform a SQL-like select request.
309
+ #
310
+ # Single record:
311
+ #
312
+ # Client.select(:first)
313
+ # Client.select(:first, :conditions=> [ "name=? AND wife=?", "Jon", "Sandy"])
314
+ # Client.select(:first, :conditions=> { :name=>"Jon", :wife=>"Sandy" }, :select => :girfriends)
315
+ #
316
+ # Bunch of records:
317
+ #
318
+ # Client.select(:all)
319
+ # Client.select(:all, :limit => 10)
320
+ # Client.select(:all, :conditions=> [ "name=? AND 'girlfriend'=?", "Jon", "Judy"])
321
+ # Client.select(:all, :conditions=> { :name=>"Sandy" }, :limit => 3)
322
+ #
323
+ # Records by ids:
324
+ #
325
+ # Client.select('1')
326
+ # Client.select('1234987b4583475347523948')
327
+ # Client.select('1','2','3','4', :conditions=> ["toys=?", "beer"])
328
+ #
329
+ # Find helpers: RightAws::ActiveSdb::Base.select_by_... and RightAws::ActiveSdb::Base.select_all_by_...
330
+ #
331
+ # Client.select_by_name('Matias Rust')
332
+ # Client.select_by_name_and_city('Putin','Moscow')
333
+ # Client.select_by_name_and_city_and_post('Medvedev','Moscow','president')
334
+ #
335
+ # Client.select_all_by_author('G.Bush jr')
336
+ # Client.select_all_by_age_and_gender_and_ethnicity('34','male','russian')
337
+ # Client.select_all_by_gender_and_country('male', 'Russia', :order => 'name')
338
+ #
339
+ # Continue listing:
340
+ #
341
+ # # initial listing
342
+ # Client.select(:all, :limit => 10)
343
+ # # continue listing
344
+ # begin
345
+ # Client.select(:all, :limit => 10, :next_token => Client.next_token)
346
+ # end while Client.next_token
347
+ #
348
+ # Sort oder:
349
+ # If :order=>'attribute' option is specified then result response (ordered by 'attribute') will contain only items where attribute is defined (is not null).
350
+ #
351
+ # Client.select(:all) # returns all records
352
+ # Client.select(:all, :order => 'gender') # returns all records ordered by gender where gender attribute exists
353
+ # Client.select(:all, :order => 'name desc') # returns all records ordered by name in desc order where name attribute exists
354
+ #
355
+ # see http://docs.amazonwebservices.com/AmazonSimpleDB/2007-11-07/DeveloperGuide/index.html?UsingSelect.html
356
+ #
357
+ def select(*args)
358
+ options = args.last.is_a?(Hash) ? args.pop : {}
359
+ case args.first
360
+ when :all then sql_select(options)
361
+ when :first then sql_select(options.merge(:limit => 1)).first
362
+ else select_from_ids args, options
363
+ end
364
+ end
365
+
366
+ def generate_id # :nodoc:
367
+ UUID.timestamp_create().to_s
368
+ end
369
+
370
+ protected
371
+
372
+ # Select
373
+
374
+ def select_from_ids(args, options) # :nodoc:
375
+ cond = []
376
+ # detect amount of records requested
377
+ bunch_of_records_requested = args.size > 1 || args.first.is_a?(Array)
378
+ # flatten ids
379
+ args = args.to_a.flatten
380
+ args.each { |id| cond << "id=#{self.connection.escape(id)}" }
381
+ ids_cond = "(#{cond.join(' OR ')})"
382
+ # user defined :conditions to string (if it was defined)
383
+ options[:conditions] = build_conditions(options[:conditions])
384
+ # join ids condition and user defined conditions
385
+ options[:conditions] = options[:conditions].blank? ? ids_cond : "(#{options[:conditions]}) AND #{ids_cond}"
386
+ result = sql_select(options)
387
+ # if one record was requested then return it
388
+ unless bunch_of_records_requested
389
+ record = result.first
390
+ # railse if nothing was found
391
+ raise ActiveSdbError.new("Couldn't find #{name} with ID #{args}") unless record
392
+ record
393
+ else
394
+ # if a bunch of records was requested then return check that we found all of them
395
+ # and return as an array
396
+ unless args.size == result.size
397
+ id_list = args.map{|i| "'#{i}'"}.join(',')
398
+ raise ActiveSdbError.new("Couldn't find all #{name} with IDs (#{id_list}) (found #{result.size} results, but was looking for #{args.size})")
399
+ else
400
+ result
401
+ end
402
+ end
403
+ end
404
+
405
+ def sql_select(options) # :nodoc:
406
+ @next_token = options[:next_token]
407
+ select_expression = build_select(options)
408
+ # request items
409
+ query_result = self.connection.select(select_expression, @next_token)
410
+ @next_token = query_result[:next_token]
411
+ items = query_result[:items].map do |hash|
412
+ id, attributes = hash.shift
413
+ new_item = self.new( attributes.merge({ 'id' => id }))
414
+ new_item.mark_as_old
415
+ new_item
416
+ end
417
+ items
418
+ end
419
+
420
+ # select_by helpers
421
+ def select_all_by_(format_str, args, options) # :nodoc:
422
+ fields = format_str.to_s.sub(/^select_(all_)?by_/,'').split('_and_')
423
+ conditions = fields.map { |field| "#{field}=?" }.join(' AND ')
424
+ options[:conditions] = [conditions, *args]
425
+ select(:all, options)
426
+ end
427
+
428
+ def select_by_(format_str, args, options) # :nodoc:
429
+ options[:limit] = 1
430
+ select_all_by_(format_str, args, options).first
431
+ end
432
+
433
+ # Query
434
+
435
+ # Returns an array of query attributes.
436
+ # Query_expression must be a well formated SDB query string:
437
+ # query_attributes("['title' starts-with 'O\\'Reily'] intersection ['year' = '2007']") #=> ["title", "year"]
438
+ def query_attributes(query_expression) # :nodoc:
439
+ attrs = []
440
+ array = query_expression.scan(/['"](.*?[^\\])['"]/).flatten
441
+ until array.empty? do
442
+ attrs << array.shift # skip it's value
443
+ array.shift #
444
+ end
445
+ attrs
446
+ end
447
+
448
+ # Returns an array of [attribute_name, 'asc'|'desc']
449
+ def sort_options(sort_string)
450
+ sort_string[/['"]?(\w+)['"]? *(asc|desc)?/i]
451
+ [$1, ($2 || 'asc')]
452
+ end
453
+
454
+ # Perform a query request.
455
+ #
456
+ # Options
457
+ # :query_expression nil | string | array
458
+ # :max_number_of_items nil | integer
459
+ # :next_token nil | string
460
+ # :sort_option nil | string "name desc|asc"
461
+ #
462
+ def query(options) # :nodoc:
463
+ @next_token = options[:next_token]
464
+ query_expression = build_conditions(options[:query_expression])
465
+ # add sort_options to the query_expression
466
+ if options[:sort_option]
467
+ sort_by, sort_order = sort_options(options[:sort_option])
468
+ sort_query_expression = "['#{sort_by}' starts-with '']"
469
+ sort_by_expression = " sort '#{sort_by}' #{sort_order}"
470
+ # make query_expression to be a string (it may be null)
471
+ query_expression = query_expression.to_s
472
+ # quote from Amazon:
473
+ # The sort attribute must be present in at least one of the predicates of the query expression.
474
+ if query_expression.blank?
475
+ query_expression = sort_query_expression
476
+ elsif !query_attributes(query_expression).include?(sort_by)
477
+ query_expression += " intersection #{sort_query_expression}"
478
+ end
479
+ query_expression += sort_by_expression
480
+ end
481
+ # request items
482
+ query_result = self.connection.query(domain, query_expression, options[:max_number_of_items], @next_token)
483
+ @next_token = query_result[:next_token]
484
+ items = query_result[:items].map do |name|
485
+ new_item = self.new('id' => name)
486
+ new_item.mark_as_old
487
+ reload_if_exists(record) if options[:auto_load]
488
+ new_item
489
+ end
490
+ items
491
+ end
492
+
493
+ # reload a record unless it is nil
494
+ def reload_if_exists(record) # :nodoc:
495
+ record && record.reload
496
+ end
497
+
498
+ def reload_all_records(*list) # :nodoc:
499
+ list.flatten.each { |record| reload_if_exists(record) }
500
+ end
501
+
502
+ def find_every(options) # :nodoc:
503
+ records = query( :query_expression => options[:conditions],
504
+ :max_number_of_items => options[:limit],
505
+ :next_token => options[:next_token],
506
+ :sort_option => options[:sort] || options[:order] )
507
+ options[:auto_load] ? reload_all_records(records) : records
508
+ end
509
+
510
+ def find_initial(options) # :nodoc:
511
+ options[:limit] = 1
512
+ record = find_every(options).first
513
+ options[:auto_load] ? reload_all_records(record).first : record
514
+ end
515
+
516
+ def find_from_ids(args, options) # :nodoc:
517
+ cond = []
518
+ # detect amount of records requested
519
+ bunch_of_records_requested = args.size > 1 || args.first.is_a?(Array)
520
+ # flatten ids
521
+ args = args.to_a.flatten
522
+ args.each { |id| cond << "'id'=#{self.connection.escape(id)}" }
523
+ ids_cond = "[#{cond.join(' OR ')}]"
524
+ # user defined :conditions to string (if it was defined)
525
+ options[:conditions] = build_conditions(options[:conditions])
526
+ # join ids condition and user defined conditions
527
+ options[:conditions] = options[:conditions].blank? ? ids_cond : "#{options[:conditions]} intersection #{ids_cond}"
528
+ result = find_every(options)
529
+ # if one record was requested then return it
530
+ unless bunch_of_records_requested
531
+ record = result.first
532
+ # railse if nothing was found
533
+ raise ActiveSdbError.new("Couldn't find #{name} with ID #{args}") unless record
534
+ options[:auto_load] ? reload_all_records(record).first : record
535
+ else
536
+ # if a bunch of records was requested then return check that we found all of them
537
+ # and return as an array
538
+ unless args.size == result.size
539
+ id_list = args.map{|i| "'#{i}'"}.join(',')
540
+ raise ActiveSdbError.new("Couldn't find all #{name} with IDs (#{id_list}) (found #{result.size} results, but was looking for #{args.size})")
541
+ else
542
+ options[:auto_load] ? reload_all_records(result) : result
543
+ end
544
+ end
545
+ end
546
+
547
+ # find_by helpers
548
+ def find_all_by_(format_str, args, options) # :nodoc:
549
+ fields = format_str.to_s.sub(/^find_(all_)?by_/,'').split('_and_')
550
+ conditions = fields.map { |field| "['#{field}'=?]" }.join(' intersection ')
551
+ options[:conditions] = [conditions, *args]
552
+ find(:all, options)
553
+ end
554
+
555
+ def find_by_(format_str, args, options) # :nodoc:
556
+ options[:limit] = 1
557
+ find_all_by_(format_str, args, options).first
558
+ end
559
+
560
+ # Misc
561
+
562
+ def method_missing(method, *args) # :nodoc:
563
+ if method.to_s[/^(find_all_by_|find_by_|select_all_by_|select_by_)/]
564
+ options = args.last.is_a?(Hash) ? args.pop : {}
565
+ __send__($1, method, args, options)
566
+ else
567
+ super(method, *args)
568
+ end
569
+ end
570
+
571
+ def build_select(options) # :nodoc:
572
+ select = options[:select] || '*'
573
+ from = options[:from] || domain
574
+ conditions = options[:conditions] ? " WHERE #{build_conditions(options[:conditions])}" : ''
575
+ order = options[:order] ? " ORDER BY #{options[:order]}" : ''
576
+ limit = options[:limit] ? " LIMIT #{options[:limit]}" : ''
577
+ # mix sort by argument (it must present in response)
578
+ unless order.blank?
579
+ sort_by, sort_order = sort_options(options[:order])
580
+ conditions << (conditions.blank? ? " WHERE " : " AND ") << "(#{sort_by} IS NOT NULL)"
581
+ end
582
+ "SELECT #{select} FROM #{from}#{conditions}#{order}#{limit}"
583
+ end
584
+
585
+ def build_conditions(conditions) # :nodoc:
586
+ case
587
+ when conditions.is_a?(Array) then connection.query_expression_from_array(conditions)
588
+ when conditions.is_a?(Hash) then connection.query_expression_from_hash(conditions)
589
+ else conditions
590
+ end
591
+ end
592
+
593
+ end
594
+
595
+ public
596
+
597
+ # instance attributes
598
+ attr_accessor :attributes
599
+
600
+ # item name
601
+ attr_accessor :id
602
+
603
+ # Create new Item instance.
604
+ # +attrs+ is a hash: { attribute1 => values1, ..., attributeN => valuesN }.
605
+ #
606
+ # item = Client.new('name' => 'Jon', 'toys' => ['girls', 'beer', 'pub'])
607
+ # puts item.inspect #=> #<Client:0xb77a2698 @new_record=true, @attributes={"name"=>["Jon"], "toys"=>["girls", "beer", "pub"]}>
608
+ # item.save #=> {"name"=>["Jon"], "id"=>"c03edb7e-e45c-11dc-bede-001bfc466dd7", "toys"=>["girls", "beer", "pub"]}
609
+ # puts item.inspect #=> #<Client:0xb77a2698 @new_record=false, @attributes={"name"=>["Jon"], "id"=>"c03edb7e-e45c-11dc-bede-001bfc466dd7", "toys"=>["girls", "beer", "pub"]}>
610
+ #
611
+ def initialize(attrs={})
612
+ @attributes = uniq_values(attrs)
613
+ @new_record = true
614
+ end
615
+
616
+ # Create and save new Item instance.
617
+ # +Attributes+ is a hash: { attribute1 => values1, ..., attributeN => valuesN }.
618
+ #
619
+ # item = Client.create('name' => 'Cat', 'toys' => ['Jons socks', 'mice', 'clew'])
620
+ # puts item.inspect #=> #<Client:0xb77a0a78 @new_record=false, @attributes={"name"=>["Cat"], "id"=>"2937601a-e45d-11dc-a75f-001bfc466dd7", "toys"=>["Jons socks", "mice", "clew"]}>
621
+ #
622
+ def self.create(attributes={})
623
+ item = self.new(attributes)
624
+ item.save
625
+ item
626
+ end
627
+
628
+ # Returns an item id. Same as: item['id'] or item.attributes['id']
629
+ def id
630
+ @attributes['id']
631
+ end
632
+
633
+ # Sets an item id.
634
+ def id=(id)
635
+ @attributes['id'] = id.to_s
636
+ end
637
+
638
+ # Returns a hash of all the attributes.
639
+ #
640
+ # puts item.attributes.inspect #=> {"name"=>["Cat"], "id"=>"2937601a-e45d-11dc-a75f-001bfc466dd7", "toys"=>["Jons socks", "clew", "mice"]}
641
+ #
642
+ def attributes
643
+ @attributes.dup
644
+ end
645
+
646
+ # Allows one to set all the attributes at once by passing in a hash with keys matching the attribute names.
647
+ # if '+id+' attribute is not set in new attributes has then it being derived from old attributes.
648
+ #
649
+ # puts item.attributes.inspect #=> {"name"=>["Cat"], "id"=>"2937601a-e45d-11dc-a75f-001bfc466dd7", "toys"=>["Jons socks", "clew", "mice"]}
650
+ # # set new attributes ('id' is missed)
651
+ # item.attributes = { 'name'=>'Dog', 'toys'=>['bones','cats'] }
652
+ # puts item.attributes.inspect #=> {"name"=>["Dog"], "id"=>"2937601a-e45d-11dc-a75f-001bfc466dd7", "toys"=>["bones", "cats"]}
653
+ # # set new attributes ('id' is set)
654
+ # item.attributes = { 'id' => 'blah-blah', 'name'=>'Birds', 'toys'=>['seeds','dogs tail'] }
655
+ # puts item.attributes.inspect #=> {"name"=>["Birds"], "id"=>"blah-blah", "toys"=>["seeds", "dogs tail"]}
656
+ #
657
+ def attributes=(attrs)
658
+ old_id = @attributes['id']
659
+ @attributes = uniq_values(attrs)
660
+ @attributes['id'] = old_id if @attributes['id'].blank? && !old_id.blank?
661
+ self.attributes
662
+ end
663
+
664
+ def connection
665
+ self.class.connection
666
+ end
667
+
668
+ # Item domain name.
669
+ def domain
670
+ self.class.domain
671
+ end
672
+
673
+ # Returns the values of the attribute identified by +attribute+.
674
+ #
675
+ # puts item['Cat'].inspect #=> ["Jons socks", "clew", "mice"]
676
+ #
677
+ def [](attribute)
678
+ @attributes[attribute.to_s]
679
+ end
680
+
681
+ # Updates the attribute identified by +attribute+ with the specified +values+.
682
+ #
683
+ # puts item['Cat'].inspect #=> ["Jons socks", "clew", "mice"]
684
+ # item['Cat'] = ["Whiskas", "chicken"]
685
+ # puts item['Cat'].inspect #=> ["Whiskas", "chicken"]
686
+ #
687
+ def []=(attribute, values)
688
+ attribute = attribute.to_s
689
+ @attributes[attribute] = attribute == 'id' ? values.to_s : values.to_a.uniq
690
+ end
691
+
692
+ # Reload attributes from SDB. Replaces in-memory attributes.
693
+ #
694
+ # item = Client.find_by_name('Cat') #=> #<Client:0xb77d0d40 @attributes={"id"=>"2937601a-e45d-11dc-a75f-001bfc466dd7"}, @new_record=false>
695
+ # item.reload #=> #<Client:0xb77d0d40 @attributes={"id"=>"2937601a-e45d-11dc-a75f-001bfc466dd7", "name"=>["Cat"], "toys"=>["Jons socks", "clew", "mice"]}, @new_record=false>
696
+ #
697
+ def reload
698
+ raise_on_id_absence
699
+ old_id = id
700
+ attrs = connection.get_attributes(domain, id)[:attributes]
701
+ @attributes = {}
702
+ unless attrs.blank?
703
+ attrs.each { |attribute, values| @attributes[attribute] = values }
704
+ @attributes['id'] = old_id
705
+ end
706
+ mark_as_old
707
+ @attributes
708
+ end
709
+
710
+ # Reload a set of attributes from SDB. Adds the loaded list to in-memory data.
711
+ # +attrs_list+ is an array or comma separated list of attributes names.
712
+ # Returns a hash of loaded attributes.
713
+ #
714
+ # This is not the best method to get a bunch of attributes because
715
+ # a web service call is being performed for every attribute.
716
+ #
717
+ # item = Client.find_by_name('Cat')
718
+ # item.reload_attributes('toys', 'name') #=> {"name"=>["Cat"], "toys"=>["Jons socks", "clew", "mice"]}
719
+ #
720
+ def reload_attributes(*attrs_list)
721
+ raise_on_id_absence
722
+ attrs_list = attrs_list.flatten.map{ |attribute| attribute.to_s }
723
+ attrs_list.delete('id')
724
+ result = {}
725
+ attrs_list.flatten.uniq.each do |attribute|
726
+ attribute = attribute.to_s
727
+ values = connection.get_attributes(domain, id, attribute)[:attributes][attribute]
728
+ unless values.blank?
729
+ @attributes[attribute] = result[attribute] = values
730
+ else
731
+ @attributes.delete(attribute)
732
+ end
733
+ end
734
+ mark_as_old
735
+ result
736
+ end
737
+
738
+ # Stores in-memory attributes to SDB.
739
+ # Adds the attributes values to already stored at SDB.
740
+ # Returns a hash of stored attributes.
741
+ #
742
+ # sandy = Client.new(:name => 'Sandy') #=> #<Client:0xb775a7a8 @attributes={"name"=>["Sandy"]}, @new_record=true>
743
+ # sandy['toys'] = 'boys'
744
+ # sandy.put
745
+ # sandy['toys'] = 'patchwork'
746
+ # sandy.put
747
+ # sandy['toys'] = 'kids'
748
+ # sandy.put
749
+ # puts sandy.attributes.inspect #=> {"name"=>["Sandy"], "id"=>"b2832ce2-e461-11dc-b13c-001bfc466dd7", "toys"=>["kids"]}
750
+ # sandy.reload #=> {"name"=>["Sandy"], "id"=>"b2832ce2-e461-11dc-b13c-001bfc466dd7", "toys"=>["boys", "kids", "patchwork"]}
751
+ #
752
+ # compare to +save+ method
753
+ def put
754
+ @attributes = uniq_values(@attributes)
755
+ prepare_for_update
756
+ attrs = @attributes.dup
757
+ attrs.delete('id')
758
+ connection.put_attributes(domain, id, attrs) unless attrs.blank?
759
+ connection.put_attributes(domain, id, { 'id' => id }, :replace)
760
+ mark_as_old
761
+ @attributes
762
+ end
763
+
764
+ # Stores specified attributes.
765
+ # +attrs+ is a hash: { attribute1 => values1, ..., attributeN => valuesN }.
766
+ # Returns a hash of saved attributes.
767
+ #
768
+ # see to +put+ method
769
+ def put_attributes(attrs)
770
+ attrs = uniq_values(attrs)
771
+ prepare_for_update
772
+ # if 'id' is present in attrs hash:
773
+ # replace internal 'id' attribute and remove it from the attributes to be sent
774
+ @attributes['id'] = attrs['id'] unless attrs['id'].blank?
775
+ attrs.delete('id')
776
+ # add new values to all attributes from list
777
+ connection.put_attributes(domain, id, attrs) unless attrs.blank?
778
+ connection.put_attributes(domain, id, { 'id' => id }, :replace)
779
+ attrs.each do |attribute, values|
780
+ @attributes[attribute] ||= []
781
+ @attributes[attribute] += values
782
+ @attributes[attribute].uniq!
783
+ end
784
+ mark_as_old
785
+ attributes
786
+ end
787
+
788
+ # Store in-memory attributes to SDB.
789
+ # Replaces the attributes values already stored at SDB by in-memory data.
790
+ # Returns a hash of stored attributes.
791
+ #
792
+ # sandy = Client.new(:name => 'Sandy') #=> #<Client:0xb775a7a8 @attributes={"name"=>["Sandy"]}, @new_record=true>
793
+ # sandy['toys'] = 'boys'
794
+ # sandy.put
795
+ # sandy['toys'] = 'patchwork'
796
+ # sandy.put
797
+ # sandy['toys'] = 'kids'
798
+ # sandy.put
799
+ # puts sandy.attributes.inspect #=> {"name"=>["Sandy"], "id"=>"b2832ce2-e461-11dc-b13c-001bfc466dd7", "toys"=>["kids"]}
800
+ # sandy.reload #=> {"name"=>["Sandy"], "id"=>"b2832ce2-e461-11dc-b13c-001bfc466dd7", "toys"=>["kids"]}
801
+ #
802
+ # compare to +put+ method
803
+ def save
804
+ @attributes = uniq_values(@attributes)
805
+ prepare_for_update
806
+ connection.put_attributes(domain, id, @attributes, :replace)
807
+ mark_as_old
808
+ @attributes
809
+ end
810
+
811
+ # Replaces the attributes at SDB by the given values.
812
+ # +Attrs+ is a hash: { attribute1 => values1, ..., attributeN => valuesN }.
813
+ # The other in-memory attributes are not being saved.
814
+ # Returns a hash of stored attributes.
815
+ #
816
+ # see +save+ method
817
+ def save_attributes(attrs)
818
+ prepare_for_update
819
+ attrs = uniq_values(attrs)
820
+ # if 'id' is present in attrs hash then replace internal 'id' attribute
821
+ unless attrs['id'].blank?
822
+ @attributes['id'] = attrs['id']
823
+ else
824
+ attrs['id'] = id
825
+ end
826
+ connection.put_attributes(domain, id, attrs, :replace) unless attrs.blank?
827
+ attrs.each { |attribute, values| attrs[attribute] = values }
828
+ mark_as_old
829
+ attrs
830
+ end
831
+
832
+ # Remove specified values from corresponding attributes.
833
+ # +attrs+ is a hash: { attribute1 => values1, ..., attributeN => valuesN }.
834
+ #
835
+ # sandy = Client.find_by_name 'Sandy'
836
+ # sandy.reload
837
+ # puts sandy.inspect #=> #<Client:0xb77b48fc @new_record=false, @attributes={"name"=>["Sandy"], "id"=>"b2832ce2-e461-11dc-b13c-001bfc466dd7", "toys"=>["boys", "kids", "patchwork"]}>
838
+ # puts sandy.delete_values('toys' => 'patchwork') #=> { 'toys' => ['patchwork'] }
839
+ # puts sandy.inspect #=> #<Client:0xb77b48fc @new_record=false, @attributes={"name"=>["Sandy"], "id"=>"b2832ce2-e461-11dc-b13c-001bfc466dd7", "toys"=>["boys", "kids"]}>
840
+ #
841
+ def delete_values(attrs)
842
+ raise_on_id_absence
843
+ attrs = uniq_values(attrs)
844
+ attrs.delete('id')
845
+ unless attrs.blank?
846
+ connection.delete_attributes(domain, id, attrs)
847
+ attrs.each do |attribute, values|
848
+ # remove the values from the attribute
849
+ if @attributes[attribute]
850
+ @attributes[attribute] -= values
851
+ else
852
+ # if the attribute is unknown remove it from a resulting list of fixed attributes
853
+ attrs.delete(attribute)
854
+ end
855
+ end
856
+ end
857
+ attrs
858
+ end
859
+
860
+ # Removes specified attributes from the item.
861
+ # +attrs_list+ is an array or comma separated list of attributes names.
862
+ # Returns the list of deleted attributes.
863
+ #
864
+ # sandy = Client.find_by_name 'Sandy'
865
+ # sandy.reload
866
+ # puts sandy.inspect #=> #<Client:0xb7761d28 @new_record=false, @attributes={"name"=>["Sandy"], "id"=>"b2832ce2-e461-11dc-b13c-001bfc466dd7", "toys"=>["boys", "kids", "patchwork"}>
867
+ # puts sandy.delete_attributes('toys') #=> ['toys']
868
+ # puts sandy.inspect #=> #<Client:0xb7761d28 @new_record=false, @attributes={"name"=>["Sandy"], "id"=>"b2832ce2-e461-11dc-b13c-001bfc466dd7"}>
869
+ #
870
+ def delete_attributes(*attrs_list)
871
+ raise_on_id_absence
872
+ attrs_list = attrs_list.flatten.map{ |attribute| attribute.to_s }
873
+ attrs_list.delete('id')
874
+ unless attrs_list.blank?
875
+ connection.delete_attributes(domain, id, attrs_list)
876
+ attrs_list.each { |attribute| @attributes.delete(attribute) }
877
+ end
878
+ attrs_list
879
+ end
880
+
881
+ # Delete the Item entirely from SDB.
882
+ #
883
+ # sandy = Client.find_by_name 'Sandy'
884
+ # sandy.reload
885
+ # sandy.inspect #=> #<Client:0xb7761d28 @new_record=false, @attributes={"name"=>["Sandy"], "id"=>"b2832ce2-e461-11dc-b13c-001bfc466dd7", "toys"=>["boys", "kids", "patchwork"}>
886
+ # puts sandy.delete
887
+ # sandy.reload
888
+ # puts sandy.inspect #=> #<Client:0xb7761d28 @attributes={}, @new_record=false>
889
+ #
890
+ def delete
891
+ raise_on_id_absence
892
+ connection.delete_attributes(domain, id)
893
+ end
894
+
895
+ # Item ID
896
+ def to_s
897
+ @id
898
+ end
899
+
900
+ # Returns true if this object hasn‘t been saved yet.
901
+ def new_record?
902
+ @new_record
903
+ end
904
+
905
+ def mark_as_old # :nodoc:
906
+ @new_record = false
907
+ end
908
+
909
+ private
910
+
911
+ def raise_on_id_absence
912
+ raise ActiveSdbError.new('Unknown record id') unless id
913
+ end
914
+
915
+ def prepare_for_update
916
+ @attributes['id'] = self.class.generate_id if @attributes['id'].blank?
917
+ end
918
+
919
+ def uniq_values(attributes=nil) # :nodoc:
920
+ attrs = {}
921
+ attributes.each do |attribute, values|
922
+ attribute = attribute.to_s
923
+ attrs[attribute] = attribute == 'id' ? values.to_s : values.to_a.uniq
924
+ attrs.delete(attribute) if values.blank?
925
+ end
926
+ attrs
927
+ end
928
+ end
929
+ end
930
+ end