sdbtools 0.1.0 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
data/Rakefile CHANGED
@@ -19,6 +19,7 @@ END
19
19
  gem.add_dependency 'highline', '~> 1.5'
20
20
  gem.add_dependency 'progressbar', '~> 0.0.3'
21
21
  gem.add_dependency 'libxml-ruby', '~> 1.1'
22
+ gem.add_dependency 'arrayfields', '~> 4.7'
22
23
  gem.add_development_dependency "rspec", ">= 1.2.9"
23
24
  end
24
25
  Jeweler::GemcutterTasks.new
data/VERSION CHANGED
@@ -1 +1 @@
1
- 0.1.0
1
+ 0.2.0
@@ -1,5 +1,6 @@
1
1
  require 'fattr'
2
2
  require 'right_aws'
3
+ require File.expand_path('selection', File.dirname(__FILE__))
3
4
 
4
5
  module SDBTools
5
6
 
@@ -8,16 +9,21 @@ module SDBTools
8
9
  class Operation
9
10
  include Enumerable
10
11
 
12
+ attr_reader :method
13
+ attr_reader :args
14
+ attr_reader :starting_token
15
+
11
16
  def initialize(sdb, method, *args)
12
17
  @options = args.last.is_a?(Hash) ? args.pop : {}
13
18
  @sdb = sdb
14
19
  @method = method
15
20
  @args = args
21
+ @starting_token = @options[:starting_token]
16
22
  end
17
23
 
18
24
  # Yields once for each result set, until there is no next token.
19
25
  def each
20
- next_token = @options[:starting_token]
26
+ next_token = starting_token
21
27
  begin
22
28
  args = @args.dup
23
29
  args << next_token
@@ -0,0 +1,189 @@
1
+ require 'arrayfields'
2
+ require 'logger'
3
+
4
+ module SDBTools
5
+ class Selection
6
+ include Enumerable
7
+
8
+ MAX_RESULT_LIMIT = 250
9
+ DEFAULT_RESULT_LIMIT = 100
10
+
11
+ attr_accessor :domain
12
+ attr_accessor :attributes
13
+ attr_accessor :conditions
14
+ attr_reader :limit
15
+ attr_accessor :offset
16
+ attr_accessor :order_by
17
+ attr_accessor :order
18
+ attr_accessor :sdb
19
+ attr_accessor :logger
20
+
21
+ # For testing
22
+ attr_writer :starting_token
23
+
24
+ def initialize(sdb, domain, options={})
25
+ @sdb = sdb
26
+ @domain = domain.to_s
27
+ @attributes = options.fetch(:attributes) { :all }
28
+ @conditions = Array(options[:conditions])
29
+ @order = options.fetch(:order) { :ascending }
30
+ @order_by = options.fetch(:order_by) { :none }
31
+ @order_by = @order_by.to_s unless @order_by == :none
32
+ self.limit = options.fetch(:limit) { DEFAULT_RESULT_LIMIT }
33
+ @offset = options.fetch(:offset) { 0 }.to_i
34
+ @logger = options.fetch(:logger){::Logger.new($stderr)}
35
+ end
36
+
37
+ def to_s
38
+ "SELECT #{output_list} FROM #{quote_name(domain)}#{match_expression}#{sort_instructions}#{limit_clause}"
39
+ end
40
+
41
+ def count_expression
42
+ "SELECT count(*) FROM #{quote_name(domain)}#{match_expression}#{sort_instructions}#{limit_clause_for_count}"
43
+ end
44
+
45
+ def offset_count_expression
46
+ "SELECT count(*) FROM #{quote_name(domain)}#{match_expression}#{sort_instructions} LIMIT #{offset}"
47
+ end
48
+
49
+ def count
50
+ @count ||= count_operation.inject(0){|count, results|
51
+ count += results[:items].first["Domain"]["Count"].first.to_i
52
+ }
53
+ end
54
+
55
+ alias_method :size, :count
56
+ alias_method :length, :count
57
+
58
+ def each
59
+ return if limit == 0
60
+ num_items = 0
61
+ select_operation.each do |results|
62
+ results[:items].each do |item|
63
+ yield(item.keys.first, item.values.first)
64
+ num_items += 1
65
+ return if limit != :none && num_items >= limit
66
+ end
67
+ end
68
+ end
69
+
70
+ def results
71
+ @results ||= inject(Arrayfields.new){|results, (name, value)|
72
+ results[name] = value
73
+ results
74
+ }
75
+ end
76
+
77
+ def count_operation
78
+ Operation.new(sdb, :select, count_expression, :starting_token => starting_token)
79
+ end
80
+
81
+ def offset_count_operation
82
+ Operation.new(sdb, :select, offset_count_expression)
83
+ end
84
+
85
+ def select_operation
86
+ Operation.new(sdb, :select, to_s, :starting_token => starting_token)
87
+ end
88
+
89
+ def starting_token
90
+ @starting_token ||=
91
+ case offset
92
+ when 0 then nil
93
+ else
94
+ op = offset_count_operation
95
+ count = 0
96
+ op.each do |results|
97
+ count += results[:items].first["Domain"]["Count"].first.to_i
98
+ if count == offset || results[:next_token].nil?
99
+ return results[:next_token]
100
+ end
101
+ end
102
+ raise "Failed to find offset #{offset}"
103
+ end
104
+ end
105
+
106
+ def limit=(new_limit)
107
+ # We can't yet support large limits. In order to do so, it will be necessary
108
+ # to implement limit chunking, where the limit is split across multiple
109
+ # requests of 250 items and a final request of limit % 250 items.
110
+ case new_limit
111
+ when :none, (0..MAX_RESULT_LIMIT) then
112
+ @limit = new_limit
113
+ else
114
+ raise RangeError, "Limit must be 0..250 or :none"
115
+ end
116
+ end
117
+
118
+ private
119
+
120
+ def quote_name(name)
121
+ if name.to_s =~ /^[A-Z$_][A-Z0-9$_]*$/i
122
+ name.to_s
123
+ else
124
+ "`" + name.to_s.gsub("`", "``") + "`"
125
+ end
126
+ end
127
+
128
+ def quote_value(value)
129
+ '"' + value.to_s.gsub(/"/, '""') + '"'
130
+ end
131
+
132
+ def output_list
133
+ case attributes
134
+ when Array then attributes.map{|a| quote_name(a)}.join(", ")
135
+ when :all then "*"
136
+ else raise ScriptError, "Bad attributes: #{attributes.inspect}"
137
+ end
138
+ end
139
+
140
+ def match_expression
141
+ case conditions.size
142
+ when 0 then ""
143
+ else " WHERE #{prepared_conditions.join(' ')}"
144
+ end
145
+ end
146
+
147
+ def prepared_conditions
148
+ conditions.map { |condition|
149
+ case condition
150
+ when String then condition
151
+ when Array then
152
+ values = condition.dup
153
+ template = values.shift.to_s
154
+ template.gsub(/\?/) {|match|
155
+ quote_value(values.shift.to_s)
156
+ }
157
+ else
158
+ raise ScriptError, "Bad condition: #{condition.inspect}"
159
+ end
160
+ }
161
+ end
162
+
163
+ def limit_clause
164
+ case limit
165
+ when :none then " LIMIT #{MAX_RESULT_LIMIT}"
166
+ when DEFAULT_RESULT_LIMIT then ""
167
+ else
168
+ batch_limit = limit < MAX_RESULT_LIMIT ? limit : MAX_RESULT_LIMIT
169
+ " LIMIT #{batch_limit}"
170
+ end
171
+ end
172
+
173
+ def limit_clause_for_count
174
+ case limit
175
+ when :none then ""
176
+ else limit_clause
177
+ end
178
+ end
179
+
180
+ def sort_instructions
181
+ case order_by
182
+ when :none then ""
183
+ else
184
+ direction = (order == :ascending ? "ASC" : "DESC")
185
+ " ORDER BY #{quote_name(order_by)} #{direction}"
186
+ end
187
+ end
188
+ end
189
+ end
@@ -0,0 +1,295 @@
1
+ require File.expand_path(File.dirname(__FILE__) + '/spec_helper')
2
+
3
+ module SDBTools
4
+ describe Selection do
5
+ def count_results(count, token=nil)
6
+ {
7
+ :items => [
8
+ "Domain" => {
9
+ "Count" => [count.to_s]
10
+ }
11
+ ],
12
+ :next_token => token
13
+ }
14
+ end
15
+
16
+ def select_results(names, token=nil)
17
+ {
18
+ :items => names.map{|n| {n => {"#{n}_attr" => ["#{n}_val"]}}},
19
+ :next_token => token
20
+ }
21
+ end
22
+
23
+ before :each do
24
+ @sdb = stub("SDB")
25
+ end
26
+
27
+ context "with domain THE_DOMAIN" do
28
+ before :each do
29
+ @it = Selection.new(@sdb, "THE_DOMAIN")
30
+ end
31
+
32
+ specify { @it.to_s.should == "SELECT * FROM THE_DOMAIN" }
33
+
34
+ it "should be able to generate a count expression" do
35
+ @it.count_expression.should be == "SELECT count(*) FROM THE_DOMAIN"
36
+ end
37
+
38
+ it "should be able to generate a count operation" do
39
+ op = @it.count_operation
40
+ op.method.should be == :select
41
+ op.args.should be == ["SELECT count(*) FROM THE_DOMAIN"]
42
+ op.starting_token.should be_nil
43
+ end
44
+
45
+ it "should generate a nil start token" do
46
+ @it.starting_token.should be_nil
47
+ end
48
+ end
49
+
50
+ context "with domain 1DO`MAIN" do
51
+ before :each do
52
+ @it = Selection.new(@sdb, "1DO`MAIN")
53
+ end
54
+
55
+ specify { @it.to_s.should == "SELECT * FROM `1DO``MAIN`" }
56
+ end
57
+
58
+ context "with explicit attributes" do
59
+ before :each do
60
+ @it = Selection.new(@sdb, "DOMAIN", :attributes => [:foo, :bar])
61
+ end
62
+
63
+ it "should select the attributes" do
64
+ @it.to_s.should == "SELECT foo, bar FROM DOMAIN"
65
+ end
66
+ end
67
+
68
+ context "with funky attribute names" do
69
+ before :each do
70
+ @it = Selection.new(@sdb, "DOMAIN",
71
+ :attributes => ["attr foo", "1`bar", "baz"])
72
+ end
73
+
74
+ it "should quote the funky attribute names" do
75
+ @it.to_s.should == %Q{SELECT `attr foo`, `1``bar`, baz FROM DOMAIN}
76
+ end
77
+ end
78
+
79
+ context "with conditions" do
80
+ before :each do
81
+ @it = Selection.new(@sdb, "DOMAIN",
82
+ :attributes => ["attr foo", "bar"],
83
+ :conditions => [
84
+ 'bar == "buz"',
85
+ "AND",
86
+ ['`attr foo` between ? and ?', 4, '1"2']])
87
+ end
88
+
89
+ it "should format quote and interpolate the conditions" do
90
+ @it.to_s.should be ==
91
+ %Q{SELECT `attr foo`, bar FROM DOMAIN WHERE bar == "buz" AND `attr foo` between "4" and "1""2"}
92
+ end
93
+
94
+ end # "
95
+
96
+ context "with a limit of :none" do
97
+ before :each do
98
+ @it = Selection.new(@sdb, "DOMAIN",
99
+ :attributes => [:foo, :bar],
100
+ :conditions => ["foo == 'bar'"],
101
+ :limit => :none)
102
+ end
103
+
104
+ it "should append a limit clause" do
105
+ @it.to_s.should be == "SELECT foo, bar FROM DOMAIN WHERE foo == 'bar' LIMIT 250"
106
+ end
107
+
108
+ it "should be able to generate a count expression" do
109
+ @it.count_expression.should be ==
110
+ "SELECT count(*) FROM DOMAIN WHERE foo == 'bar'"
111
+ end
112
+ end
113
+
114
+ context "with a limit" do
115
+ before :each do
116
+ @it = Selection.new(@sdb, "DOMAIN",
117
+ :attributes => [:foo, :bar],
118
+ :conditions => ["foo == 'bar'"],
119
+ :limit => 10)
120
+ end
121
+
122
+ it "should append a limit clause" do
123
+ @it.to_s.should be == "SELECT foo, bar FROM DOMAIN WHERE foo == 'bar' LIMIT 10"
124
+ end
125
+
126
+ it "should be able to generate a count expression" do
127
+ @it.count_expression.should be ==
128
+ "SELECT count(*) FROM DOMAIN WHERE foo == 'bar' LIMIT 10"
129
+ end
130
+
131
+ it "should limit results" do
132
+ results = select_results(Array.new(100, "foo"))
133
+ @sdb.stub!(:select).and_return(results)
134
+ @it.to_a.size.should == 10
135
+ end
136
+ end
137
+
138
+ context "ordered by an attribute" do
139
+ before :each do
140
+ @it = Selection.new(@sdb, "DOMAIN",
141
+ :attributes => [:foo, :bar],
142
+ :conditions => ["foo == 'bar'"],
143
+ :order_by => "foo")
144
+ end
145
+
146
+ it "should append an order clause" do
147
+ @it.to_s.should be ==
148
+ "SELECT foo, bar FROM DOMAIN WHERE foo == 'bar' ORDER BY foo ASC"
149
+ end
150
+
151
+ it "should be able to generate a count expression" do
152
+ @it.count_expression.should be ==
153
+ "SELECT count(*) FROM DOMAIN WHERE foo == 'bar' ORDER BY foo ASC"
154
+ end
155
+ end
156
+
157
+ context "in descending order" do
158
+ before :each do
159
+ @it = Selection.new(@sdb, "DOMAIN",
160
+ :attributes => [:foo, :bar],
161
+ :conditions => ["foo == 'bar'"],
162
+ :order_by => "foo",
163
+ :order => :descending)
164
+ end
165
+
166
+ it "should append an order clause" do
167
+ @it.to_s.should be ==
168
+ "SELECT foo, bar FROM DOMAIN WHERE foo == 'bar' ORDER BY foo DESC"
169
+ end
170
+
171
+ it "should be able to generate a count expression" do
172
+ @it.count_expression.should be ==
173
+ "SELECT count(*) FROM DOMAIN WHERE foo == 'bar' ORDER BY foo DESC"
174
+ end
175
+
176
+ it "should be able to generate an offset count expression" do
177
+ @it.offset_count_expression.should be ==
178
+ "SELECT count(*) FROM DOMAIN WHERE foo == 'bar' ORDER BY foo DESC LIMIT 0"
179
+ end
180
+
181
+ context "with a limit" do
182
+ before :each do
183
+ @it.limit = 250
184
+ end
185
+
186
+ specify { @it.to_s.should be ==
187
+ "SELECT foo, bar FROM DOMAIN WHERE foo == 'bar' ORDER BY foo DESC LIMIT 250"
188
+ }
189
+ end
190
+ end
191
+
192
+ # We can't yet support large limits. In order to do so, it will be necessary
193
+ # to implement limit chunking, where the limit is split across multiple
194
+ # requests of 250 items and a final request of limit % 250 items.
195
+ it "should reject limits > 250" do
196
+ lambda do
197
+ Selection.new(@sdb, "DOMAIN", :limit => 251)
198
+ end.should raise_error
199
+ lambda do
200
+ Selection.new(@sdb, "DOMAIN").limit = 251
201
+ end.should raise_error
202
+ end
203
+
204
+ context "with an offset" do
205
+ before :each do
206
+ @it = Selection.new(@sdb, "DOMAIN",
207
+ :attributes => [:foo, :bar],
208
+ :conditions => ["foo == 'bar'"],
209
+ :limit => 10,
210
+ :offset => 200)
211
+
212
+ # Note: counts are relative to the last count, not absolute
213
+ @first_results = count_results(100, "TOKEN1")
214
+ @second_results = count_results(100, "TOKEN2")
215
+ @third_results = count_results(100, nil)
216
+ @sdb.stub!(:select).
217
+ and_return(@first_results, @second_results, @third_results)
218
+ end
219
+
220
+ it "should be able to generate an offset count expression" do
221
+ @it.offset_count_expression.should be ==
222
+ "SELECT count(*) FROM DOMAIN WHERE foo == 'bar' LIMIT 200"
223
+ end
224
+
225
+ it "should be able to generate an offset count operation" do
226
+ op = @it.offset_count_operation
227
+ op.method.should be == :select
228
+ op.args.should be == [
229
+ "SELECT count(*) FROM DOMAIN WHERE foo == 'bar' LIMIT 200"
230
+ ]
231
+ op.starting_token.should be_nil
232
+ end
233
+
234
+ it "should use an SDB count to determine starting token" do
235
+ @sdb = stub("SDB")
236
+ @sdb.should_receive(:select).
237
+ with(@it.offset_count_expression, nil).
238
+ ordered.
239
+ and_return(@first_results)
240
+ @sdb.should_receive(:select).
241
+ with(@it.offset_count_expression, "TOKEN1").
242
+ ordered.
243
+ and_return(@second_results)
244
+ @sdb.should_not_receive(:select).
245
+ with(@it.offset_count_expression, "TOKEN2")
246
+ @it.sdb = @sdb
247
+ @it.starting_token.should be == "TOKEN2"
248
+ end
249
+
250
+ it "should be able to generate a select operation" do
251
+ op = @it.select_operation
252
+ op.args.should be == [
253
+ "SELECT foo, bar FROM DOMAIN WHERE foo == 'bar' LIMIT 10"
254
+ ]
255
+ op.starting_token.should be == "TOKEN2"
256
+ end
257
+
258
+ it "should be able to count items in the selection" do
259
+ @sdb = stub("SDB")
260
+ @sdb.should_receive(:select).
261
+ with(@it.count_expression, "TOKEN2").
262
+ and_return(count_results(4, "TOKEN5"))
263
+ @sdb.should_receive(:select).
264
+ with(@it.count_expression, "TOKEN5").
265
+ and_return(count_results(5, nil))
266
+ @it.sdb = @sdb
267
+ @it.starting_token = "TOKEN2"
268
+ @it.size.should be == 9
269
+ @it.length.should be == 9
270
+ @it.count.should be == 9
271
+ end
272
+
273
+ it "should be able to get selection results" do
274
+ @sdb = stub("SDB")
275
+ @sdb.should_receive(:select).
276
+ with(@it.to_s, "TOKEN2").
277
+ and_return(select_results(["foo", "bar"], "TOKEN5"))
278
+ @sdb.should_receive(:select).
279
+ with(@it.to_s, "TOKEN5").
280
+ and_return(select_results(["baz", "buz"], nil))
281
+ @it.sdb = @sdb
282
+ @it.starting_token = "TOKEN2"
283
+ @it.results.values.should be == [
284
+ { "foo_attr" => ["foo_val"]},
285
+ { "bar_attr" => ["bar_val"]},
286
+ { "baz_attr" => ["baz_val"]},
287
+ { "buz_attr" => ["buz_val"]}
288
+ ]
289
+ @it.results.keys.should be == [
290
+ "foo", "bar", "baz", "buz"
291
+ ]
292
+ end
293
+ end
294
+ end
295
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sdbtools
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Avdi Grimm
@@ -9,7 +9,7 @@ autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
11
 
12
- date: 2010-01-18 00:00:00 -05:00
12
+ date: 2010-01-19 00:00:00 -05:00
13
13
  default_executable: sdbtool
14
14
  dependencies:
15
15
  - !ruby/object:Gem::Dependency
@@ -72,6 +72,16 @@ dependencies:
72
72
  - !ruby/object:Gem::Version
73
73
  version: "1.1"
74
74
  version:
75
+ - !ruby/object:Gem::Dependency
76
+ name: arrayfields
77
+ type: :runtime
78
+ version_requirement:
79
+ version_requirements: !ruby/object:Gem::Requirement
80
+ requirements:
81
+ - - ~>
82
+ - !ruby/object:Gem::Version
83
+ version: "4.7"
84
+ version:
75
85
  - !ruby/object:Gem::Dependency
76
86
  name: rspec
77
87
  type: :development
@@ -98,7 +108,9 @@ files:
98
108
  - VERSION
99
109
  - bin/sdbtool
100
110
  - lib/sdbtools.rb
111
+ - lib/selection.rb
101
112
  - spec/operation_spec.rb
113
+ - spec/selection_spec.rb
102
114
  - spec/spec_helper.rb
103
115
  has_rdoc: true
104
116
  homepage: http://github.com/devver/sdbtools
@@ -129,5 +141,6 @@ signing_key:
129
141
  specification_version: 3
130
142
  summary: A high-level OO interface to Amazon SimpleDB
131
143
  test_files:
144
+ - spec/selection_spec.rb
132
145
  - spec/operation_spec.rb
133
146
  - spec/spec_helper.rb