aws 1.10.1

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