dynamoid 3.2.0 → 3.6.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.
Files changed (78) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +111 -1
  3. data/README.md +580 -241
  4. data/lib/dynamoid.rb +2 -0
  5. data/lib/dynamoid/adapter.rb +15 -15
  6. data/lib/dynamoid/adapter_plugin/aws_sdk_v3.rb +82 -102
  7. data/lib/dynamoid/adapter_plugin/aws_sdk_v3/batch_get_item.rb +108 -0
  8. data/lib/dynamoid/adapter_plugin/aws_sdk_v3/create_table.rb +29 -16
  9. data/lib/dynamoid/adapter_plugin/aws_sdk_v3/item_updater.rb +3 -2
  10. data/lib/dynamoid/adapter_plugin/aws_sdk_v3/middleware/backoff.rb +2 -2
  11. data/lib/dynamoid/adapter_plugin/aws_sdk_v3/middleware/limit.rb +2 -3
  12. data/lib/dynamoid/adapter_plugin/aws_sdk_v3/middleware/start_key.rb +2 -2
  13. data/lib/dynamoid/adapter_plugin/aws_sdk_v3/query.rb +15 -6
  14. data/lib/dynamoid/adapter_plugin/aws_sdk_v3/scan.rb +15 -5
  15. data/lib/dynamoid/adapter_plugin/aws_sdk_v3/table.rb +1 -0
  16. data/lib/dynamoid/adapter_plugin/aws_sdk_v3/until_past_table_status.rb +5 -3
  17. data/lib/dynamoid/application_time_zone.rb +1 -0
  18. data/lib/dynamoid/associations.rb +182 -19
  19. data/lib/dynamoid/associations/association.rb +4 -2
  20. data/lib/dynamoid/associations/belongs_to.rb +2 -1
  21. data/lib/dynamoid/associations/has_and_belongs_to_many.rb +2 -1
  22. data/lib/dynamoid/associations/has_many.rb +2 -1
  23. data/lib/dynamoid/associations/has_one.rb +2 -1
  24. data/lib/dynamoid/associations/many_association.rb +65 -22
  25. data/lib/dynamoid/associations/single_association.rb +28 -1
  26. data/lib/dynamoid/components.rb +8 -3
  27. data/lib/dynamoid/config.rb +16 -3
  28. data/lib/dynamoid/config/backoff_strategies/constant_backoff.rb +1 -0
  29. data/lib/dynamoid/config/backoff_strategies/exponential_backoff.rb +1 -0
  30. data/lib/dynamoid/config/options.rb +1 -0
  31. data/lib/dynamoid/criteria.rb +2 -1
  32. data/lib/dynamoid/criteria/chain.rb +418 -46
  33. data/lib/dynamoid/criteria/ignored_conditions_detector.rb +3 -3
  34. data/lib/dynamoid/criteria/key_fields_detector.rb +109 -32
  35. data/lib/dynamoid/criteria/nonexistent_fields_detector.rb +3 -2
  36. data/lib/dynamoid/criteria/overwritten_conditions_detector.rb +1 -1
  37. data/lib/dynamoid/dirty.rb +239 -32
  38. data/lib/dynamoid/document.rb +130 -251
  39. data/lib/dynamoid/dumping.rb +9 -0
  40. data/lib/dynamoid/dynamodb_time_zone.rb +1 -0
  41. data/lib/dynamoid/fields.rb +246 -20
  42. data/lib/dynamoid/finders.rb +69 -32
  43. data/lib/dynamoid/identity_map.rb +6 -0
  44. data/lib/dynamoid/indexes.rb +76 -17
  45. data/lib/dynamoid/loadable.rb +31 -0
  46. data/lib/dynamoid/log/formatter.rb +26 -0
  47. data/lib/dynamoid/middleware/identity_map.rb +1 -0
  48. data/lib/dynamoid/persistence.rb +592 -122
  49. data/lib/dynamoid/persistence/import.rb +73 -0
  50. data/lib/dynamoid/persistence/save.rb +64 -0
  51. data/lib/dynamoid/persistence/update_fields.rb +63 -0
  52. data/lib/dynamoid/persistence/upsert.rb +60 -0
  53. data/lib/dynamoid/primary_key_type_mapping.rb +1 -0
  54. data/lib/dynamoid/railtie.rb +1 -0
  55. data/lib/dynamoid/tasks.rb +3 -1
  56. data/lib/dynamoid/tasks/database.rb +1 -0
  57. data/lib/dynamoid/type_casting.rb +12 -2
  58. data/lib/dynamoid/undumping.rb +8 -0
  59. data/lib/dynamoid/validations.rb +2 -0
  60. data/lib/dynamoid/version.rb +1 -1
  61. metadata +49 -71
  62. data/.coveralls.yml +0 -1
  63. data/.document +0 -5
  64. data/.gitignore +0 -74
  65. data/.rspec +0 -2
  66. data/.rubocop.yml +0 -71
  67. data/.rubocop_todo.yml +0 -55
  68. data/.travis.yml +0 -41
  69. data/Appraisals +0 -28
  70. data/Gemfile +0 -8
  71. data/Rakefile +0 -46
  72. data/Vagrantfile +0 -29
  73. data/docker-compose.yml +0 -7
  74. data/dynamoid.gemspec +0 -57
  75. data/gemfiles/rails_4_2.gemfile +0 -11
  76. data/gemfiles/rails_5_0.gemfile +0 -10
  77. data/gemfiles/rails_5_1.gemfile +0 -10
  78. data/gemfiles/rails_5_2.gemfile +0 -10
@@ -2,6 +2,7 @@
2
2
 
3
3
  module Dynamoid
4
4
  module Config
5
+ # @private
5
6
  module BackoffStrategies
6
7
  class ConstantBackoff
7
8
  def self.call(sec = 1)
@@ -2,6 +2,7 @@
2
2
 
3
3
  module Dynamoid
4
4
  module Config
5
+ # @private
5
6
  module BackoffStrategies
6
7
  # Truncated binary exponential backoff algorithm
7
8
  # See https://en.wikipedia.org/wiki/Exponential_backoff
@@ -4,6 +4,7 @@
4
4
  module Dynamoid
5
5
  module Config
6
6
  # Encapsulates logic for setting options.
7
+ # @private
7
8
  module Options
8
9
  # Get the defaults or initialize a new empty hash.
9
10
  #
@@ -7,8 +7,9 @@ module Dynamoid
7
7
  module Criteria
8
8
  extend ActiveSupport::Concern
9
9
 
10
+ # @private
10
11
  module ClassMethods
11
- %i[where all first last each record_limit scan_limit batch start scan_index_forward find_by_pages].each do |meth|
12
+ %i[where all first last each record_limit scan_limit batch start scan_index_forward find_by_pages project pluck].each do |meth|
12
13
  # Return a criteria chain in response to a method that will begin or end a chain. For more information,
13
14
  # see Dynamoid::Criteria::Chain.
14
15
  #
@@ -22,26 +22,74 @@ module Dynamoid
22
22
  @consistent_read = false
23
23
  @scan_index_forward = true
24
24
 
25
- # Honor STI and :type field if it presents
26
- type = @source.inheritance_field
27
- if @source.attributes.key?(type)
28
- @query[:"#{type}.in"] = @source.deep_subclasses.map(&:name) << @source.name
29
- end
30
-
31
25
  # we should re-initialize keys detector every time we change query
32
26
  @key_fields_detector = KeyFieldsDetector.new(@query, @source)
33
27
  end
34
28
 
35
- # The workhorse method of the criteria chain. Each key in the passed in hash will become another criteria that the
36
- # ultimate query must match. A key can either be a symbol or a string, and should be an attribute name or
37
- # an attribute name with a range operator.
29
+ # Returns a chain which is a result of filtering current chain with the specified conditions.
30
+ #
31
+ # It accepts conditions in the form of a hash.
32
+ #
33
+ # Post.where(links_count: 2)
34
+ #
35
+ # A key could be either string or symbol.
36
+ #
37
+ # In order to express conditions other than equality predicates could be used.
38
+ # Predicate should be added to an attribute name to form a key +'created_at.gt' => Date.yesterday+
39
+ #
40
+ # Currently supported following predicates:
41
+ # - +gt+ - greater than
42
+ # - +gte+ - greater or equal
43
+ # - +lt+ - less than
44
+ # - +lte+ - less or equal
45
+ # - +ne+ - not equal
46
+ # - +between+ - an attribute value is greater than the first value and less than the second value
47
+ # - +in+ - check an attribute in a list of values
48
+ # - +begins_with+ - check for a prefix in string
49
+ # - +contains+ - check substring or value in a set or array
50
+ # - +not_contains+ - check for absence of substring or a value in set or array
51
+ # - +null+ - attribute doesn't exists in an item
52
+ # - +not_null+ - attribute exists in an item
53
+ #
54
+ # All the predicates match operators supported by DynamoDB's
55
+ # {ComparisonOperator}[https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_Condition.html#DDB-Type-Condition-ComparisonOperator]
56
+ #
57
+ # Post.where('size.gt' => 1000)
58
+ # Post.where('size.gte' => 1000)
59
+ # Post.where('size.lt' => 35000)
60
+ # Post.where('size.lte' => 35000)
61
+ # Post.where('author.ne' => 'John Doe')
62
+ # Post.where('created_at.between' => [Time.now - 3600, Time.now])
63
+ # Post.where('category.in' => ['tech', 'fashion'])
64
+ # Post.where('title.begins_with' => 'How long')
65
+ # Post.where('tags.contains' => 'Ruby')
66
+ # Post.where('tags.not_contains' => 'Ruby on Rails')
67
+ # Post.where('legacy_attribute.null' => true)
68
+ # Post.where('optional_attribute.not_null' => true)
69
+ #
70
+ # There are some limitations for a sort key. Only following predicates
71
+ # are supported - +gt+, +gte+, +lt+, +lte+, +between+, +begins_with+.
72
+ #
73
+ # +where+ without argument will return the current chain.
74
+ #
75
+ # Multiple calls can be chained together and conditions will be merged:
76
+ #
77
+ # Post.where('size.gt' => 1000).where('title' => 'some title')
78
+ #
79
+ # It's equivalent to:
80
+ #
81
+ # Post.where('size.gt' => 1000, 'title' => 'some title')
38
82
  #
39
- # @example A simple criteria
40
- # where(:name => 'Josh')
83
+ # But only one condition can be specified for a certain attribute. The
84
+ # last specified condition will override all the others. Only condition
85
+ # 'size.lt' => 200 will be used in following examples:
41
86
  #
42
- # @example A more complicated criteria
43
- # where(:name => 'Josh', 'created_at.gt' => DateTime.now - 1.day)
87
+ # Post.where('size.gt' => 100, 'size.lt' => 200)
88
+ # Post.where('size.gt' => 100).where('size.lt' => 200)
44
89
  #
90
+ # Internally +where+ performs either +Scan+ or +Query+ operation.
91
+ #
92
+ # @return [Dynamoid::Criteria::Chain]
45
93
  # @since 0.2.0
46
94
  def where(args)
47
95
  detector = IgnoredConditionsDetector.new(args)
@@ -67,6 +115,13 @@ module Dynamoid
67
115
  self
68
116
  end
69
117
 
118
+ # Turns on strongly consistent reads.
119
+ #
120
+ # By default reads are eventually consistent.
121
+ #
122
+ # Post.where('size.gt' => 1000).consistent
123
+ #
124
+ # @return [Dynamoid::Criteria::Chain]
70
125
  def consistent
71
126
  @consistent_read = true
72
127
  self
@@ -74,11 +129,39 @@ module Dynamoid
74
129
 
75
130
  # Returns all the records matching the criteria.
76
131
  #
132
+ # Since +where+ and most of the other methods return a +Chain+
133
+ # the only way to get a result as a collection is to call the +all+
134
+ # method. It returns +Enumerator+ which could be used directly or
135
+ # transformed into +Array+
136
+ #
137
+ # Post.all # => Enumerator
138
+ # Post.where(links_count: 2).all # => Enumerator
139
+ # Post.where(links_count: 2).all.to_a # => Array
140
+ #
141
+ # When the result set is too large DynamoDB divides it into separate
142
+ # pages. While an enumerator iterates over the result models each page
143
+ # is loaded lazily. So even an extra large result set can be loaded and
144
+ # processed with considerably small memory footprint and throughput
145
+ # consumption.
146
+ #
147
+ # @return [Enumerator::Lazy]
77
148
  # @since 0.2.0
78
149
  def all
79
150
  records
80
151
  end
81
152
 
153
+ # Returns the actual number of items in a table matching the criteria.
154
+ #
155
+ # Post.where(links_count: 2).count
156
+ #
157
+ # Internally it uses either `Scan` or `Query` DynamoDB's operation so it
158
+ # costs like all the matching items were read from a table.
159
+ #
160
+ # The only difference is that items are read by DynemoDB but not actually
161
+ # loaded on the client side. DynamoDB returns only count of items after
162
+ # filtering.
163
+ #
164
+ # @return [Integer]
82
165
  def count
83
166
  if @key_fields_detector.key_present?
84
167
  count_via_query
@@ -87,27 +170,72 @@ module Dynamoid
87
170
  end
88
171
  end
89
172
 
90
- # Returns the last fetched record matched the criteria
91
- # Enumerable doesn't implement `last`, only `first`
92
- # So we have to implement it ourselves
173
+ # Returns the first item matching the criteria.
174
+ #
175
+ # Post.where(links_count: 2).first
176
+ #
177
+ # Applies `record_limit(1)` to ensure only a single record is fetched
178
+ # when no non-key conditions are present and `scan_limit(1)` when no
179
+ # conditions are present at all.
93
180
  #
181
+ # If used without criteria it just returns the first item of some
182
+ # arbitrary order.
183
+ #
184
+ # Post.first
185
+ #
186
+ # @return [Model|nil]
187
+ def first(*args)
188
+ n = args.first || 1
189
+
190
+ return scan_limit(n).to_a.first(*args) if @query.blank?
191
+ return super if @key_fields_detector.non_key_present?
192
+
193
+ record_limit(n).to_a.first(*args)
194
+ end
195
+
196
+ # Returns the last item matching the criteria.
197
+ #
198
+ # Post.where(links_count: 2).last
199
+ #
200
+ # DynamoDB doesn't support ordering by some arbitrary attribute except a
201
+ # sort key. So this method is mostly useful during development and
202
+ # testing.
203
+ #
204
+ # If used without criteria it just returns the last item of some arbitrary order.
205
+ #
206
+ # Post.last
207
+ #
208
+ # It isn't efficient from the performance point of view as far as it reads and
209
+ # loads all the filtered items from DynamoDB.
210
+ #
211
+ # @return [Model|nil]
94
212
  def last
95
213
  all.to_a.last
96
214
  end
97
215
 
98
- # Destroys all the records matching the criteria.
216
+ # Deletes all the items matching the criteria.
217
+ #
218
+ # Post.where(links_count: 2).delete_all
99
219
  #
220
+ # If called without criteria then it deletes all the items in a table.
221
+ #
222
+ # Post.delete_all
223
+ #
224
+ # It loads all the items either with +Scan+ or +Query+ operation and
225
+ # deletes them in batch with +BatchWriteItem+ operation. +BatchWriteItem+
226
+ # is limited by request size and items count so it's quite possible the
227
+ # deletion will require several +BatchWriteItem+ calls.
100
228
  def delete_all
101
229
  ids = []
102
230
  ranges = []
103
231
 
104
232
  if @key_fields_detector.key_present?
105
- Dynamoid.adapter.query(source.table_name, range_query).flat_map{ |i| i }.collect do |hash|
233
+ Dynamoid.adapter.query(source.table_name, range_query).flat_map { |i| i }.collect do |hash|
106
234
  ids << hash[source.hash_key.to_sym]
107
235
  ranges << hash[source.range_key.to_sym] if source.range_key
108
236
  end
109
237
  else
110
- Dynamoid.adapter.scan(source.table_name, scan_query, scan_opts).flat_map{ |i| i }.collect do |hash|
238
+ Dynamoid.adapter.scan(source.table_name, scan_query, scan_opts).flat_map { |i| i }.collect do |hash|
111
239
  ids << hash[source.hash_key.to_sym]
112
240
  ranges << hash[source.range_key.to_sym] if source.range_key
113
241
  end
@@ -117,48 +245,229 @@ module Dynamoid
117
245
  end
118
246
  alias destroy_all delete_all
119
247
 
120
- # The record limit is the limit of evaluated records returned by the
121
- # query or scan.
248
+ # Set the record limit.
249
+ #
250
+ # The record limit is the limit of evaluated items returned by the
251
+ # +Query+ or +Scan+. In other words it's how many items should be
252
+ # returned in response.
253
+ #
254
+ # Post.where(links_count: 2).record_limit(1000) # => 1000 models
255
+ # Post.record_limit(1000) # => 1000 models
256
+ #
257
+ # It could be very inefficient in terms of HTTP requests in pathological
258
+ # cases. DynamoDB doesn't support out of the box the limits for items
259
+ # count after filtering. So it's possible to make a lot of HTTP requests
260
+ # to find items matching criteria and skip not matching. It means that
261
+ # the cost (read capacity units) is unpredictable.
262
+ #
263
+ # Because of such issues with performance and cost it's mostly useful in
264
+ # development and testing.
265
+ #
266
+ # When called without criteria it works like +scan_limit+.
267
+ #
268
+ # @return [Dynamoid::Criteria::Chain]
122
269
  def record_limit(limit)
123
270
  @record_limit = limit
124
271
  self
125
272
  end
126
273
 
127
- # The scan limit which is the limit of records that DynamoDB will
128
- # internally query or scan. This is different from the record limit
129
- # as with filtering DynamoDB may look at N scanned records but return 0
130
- # records if none pass the filter.
274
+ # Set the scan limit.
275
+ #
276
+ # The scan limit is the limit of records that DynamoDB will internally
277
+ # read with +Query+ or +Scan+. It's different from the record limit as
278
+ # with filtering DynamoDB may look at N scanned items but return 0
279
+ # items if none passes the filter. So it can return less items than was
280
+ # specified with the limit.
281
+ #
282
+ # Post.where(links_count: 2).scan_limit(1000) # => 850 models
283
+ # Post.scan_limit(1000) # => 1000 models
284
+ #
285
+ # By contrast with +record_limit+ the cost (read capacity units) and
286
+ # performance is predictable.
287
+ #
288
+ # When called without criteria it works like +record_limit+.
289
+ #
290
+ # @return [Dynamoid::Criteria::Chain]
131
291
  def scan_limit(limit)
132
292
  @scan_limit = limit
133
293
  self
134
294
  end
135
295
 
296
+ # Set the batch size.
297
+ #
298
+ # The batch size is a number of items which will be lazily loaded one by one.
299
+ # When the batch size is set then items will be loaded batch by batch of
300
+ # the specified size instead of relying on the default paging mechanism
301
+ # of DynamoDB.
302
+ #
303
+ # Post.where(links_count: 2).batch(1000).all.each do |post|
304
+ # # process a post
305
+ # end
306
+ #
307
+ # It's useful to limit memory usage or throughput consumption
308
+ #
309
+ # @return [Dynamoid::Criteria::Chain]
136
310
  def batch(batch_size)
137
311
  @batch_size = batch_size
138
312
  self
139
313
  end
140
314
 
315
+ # Set the start item.
316
+ #
317
+ # When the start item is set the items will be loaded starting right
318
+ # after the specified item.
319
+ #
320
+ # Post.where(links_count: 2).start(post)
321
+ #
322
+ # It can be used to implement an own pagination mechanism.
323
+ #
324
+ # Post.where(author_id: author_id).start(last_post).scan_limit(50)
325
+ #
326
+ # The specified start item will not be returned back in a result set.
327
+ #
328
+ # Actually it doesn't need all the item attributes to start - an item may
329
+ # have only the primary key attributes (partition and sort key if it's
330
+ # declared).
331
+ #
332
+ # Post.where(links_count: 2).start(Post.new(id: id))
333
+ #
334
+ # It also supports a +Hash+ argument with the keys attributes - a
335
+ # partition key and a sort key (if it's declared).
336
+ #
337
+ # Post.where(links_count: 2).start(id: id)
338
+ #
339
+ # @return [Dynamoid::Criteria::Chain]
141
340
  def start(start)
142
341
  @start = start
143
342
  self
144
343
  end
145
344
 
345
+ # Reverse the sort order.
346
+ #
347
+ # By default the sort order is ascending (by the sort key value). Set a
348
+ # +false+ value to reverse the order.
349
+ #
350
+ # Post.where(id: id, 'views_count.gt' => 1000).scan_index_forward(false)
351
+ #
352
+ # It works only for queries with a partition key condition e.g. +id:
353
+ # 'some-id'+ which internally performs +Query+ operation.
354
+ #
355
+ # @return [Dynamoid::Criteria::Chain]
146
356
  def scan_index_forward(scan_index_forward)
147
357
  @scan_index_forward = scan_index_forward
148
358
  self
149
359
  end
150
360
 
151
- # Allows you to use the results of a search as an enumerable over the results found.
361
+ # Allows to use the results of a search as an enumerable over the results
362
+ # found.
363
+ #
364
+ # Post.each do |post|
365
+ # end
366
+ #
367
+ # Post.all.each do |post|
368
+ # end
369
+ #
370
+ # Post.where(links_count: 2).each do |post|
371
+ # end
372
+ #
373
+ # It works similar to the +all+ method so results are loaded lazily.
152
374
  #
153
375
  # @since 0.2.0
154
376
  def each(&block)
155
377
  records.each(&block)
156
378
  end
157
379
 
380
+ # Iterates over the pages returned by DynamoDB.
381
+ #
382
+ # DynamoDB has its own paging machanism and divides a large result set
383
+ # into separate pages. The +find_by_pages+ method provides access to
384
+ # these native DynamoDB pages.
385
+ #
386
+ # The pages are loaded lazily.
387
+ #
388
+ # Post.where('views_count.gt' => 1000).find_by_pages do |posts, options|
389
+ # # process posts
390
+ # end
391
+ #
392
+ # It passes as block argument an +Array+ of models and a Hash with options.
393
+ #
394
+ # Options +Hash+ contains only one option +:last_evaluated_key+. The last
395
+ # evaluated key is a Hash with key attributes of the last item processed by
396
+ # DynamoDB. It can be used to resume querying using the +start+ method.
397
+ #
398
+ # posts, options = Post.where('views_count.gt' => 1000).find_by_pages.first
399
+ # last_key = options[:last_evaluated_key]
400
+ #
401
+ # # ...
402
+ #
403
+ # Post.where('views_count.gt' => 1000).start(last_key).find_by_pages do |posts, options|
404
+ # end
405
+ #
406
+ # If it's called without a block then it returns an +Enumerator+.
407
+ #
408
+ # enum = Post.where('views_count.gt' => 1000).find_by_pages
409
+ #
410
+ # enum.each do |posts, options|
411
+ # # process posts
412
+ # end
413
+ #
414
+ # @return [Enumerator::Lazy]
158
415
  def find_by_pages(&block)
159
416
  pages.each(&block)
160
417
  end
161
418
 
419
+ # Select only specified fields.
420
+ #
421
+ # It takes one or more field names and returns a collection of models with only
422
+ # these fields set.
423
+ #
424
+ # Post.where('views_count.gt' => 1000).select(:title)
425
+ # Post.where('views_count.gt' => 1000).select(:title, :created_at)
426
+ # Post.select(:id)
427
+ #
428
+ # It can be used to avoid loading large field values and to decrease a
429
+ # memory footprint.
430
+ #
431
+ # @return [Dynamoid::Criteria::Chain]
432
+ def project(*fields)
433
+ @project = fields.map(&:to_sym)
434
+ self
435
+ end
436
+
437
+ # Select only specified fields.
438
+ #
439
+ # It takes one or more field names and returns an array of either values
440
+ # or arrays of values.
441
+ #
442
+ # Post.pluck(:id) # => ['1', '2']
443
+ # Post.pluck(:title, :title) # => [['1', 'Title #1'], ['2', 'Title#2']]
444
+ #
445
+ # Post.where('views_count.gt' => 1000).pluck(:title)
446
+ #
447
+ # There are some differences between +pluck+ and +project+. +pluck+
448
+ # - doesn't instantiate models
449
+ # - it isn't chainable and returns +Array+ instead of +Chain+
450
+ #
451
+ # It deserializes values if a field type isn't supported by DynamoDB natively.
452
+ #
453
+ # It can be used to avoid loading large field values and to decrease a
454
+ # memory footprint.
455
+ #
456
+ # @return [Array]
457
+ def pluck(*args)
458
+ fields = args.map(&:to_sym)
459
+ @project = fields
460
+
461
+ if fields.many?
462
+ items.map do |item|
463
+ fields.map { |key| Undumping.undump_field(item[key], source.attributes[key]) }
464
+ end.to_a
465
+ else
466
+ key = fields.first
467
+ items.map { |item| Undumping.undump_field(item[key], source.attributes[key]) }.to_a
468
+ end
469
+ end
470
+
162
471
  private
163
472
 
164
473
  # The actual records referenced by the association.
@@ -167,7 +476,12 @@ module Dynamoid
167
476
  #
168
477
  # @since 0.2.0
169
478
  def records
170
- pages.lazy.flat_map { |i| i }
479
+ pages.lazy.flat_map { |items, _| items }
480
+ end
481
+
482
+ # Raw items like they are stored before type casting
483
+ def items
484
+ raw_pages.lazy.flat_map { |items, _| items }
171
485
  end
172
486
 
173
487
  # Arrays of records, sized based on the actual pages produced by DynamoDB
@@ -176,11 +490,19 @@ module Dynamoid
176
490
  #
177
491
  # @since 3.1.0
178
492
  def pages
493
+ raw_pages.lazy.map do |items, options|
494
+ models = items.map { |i| source.from_database(i) }
495
+ [models, options]
496
+ end.each
497
+ end
498
+
499
+ # Pages of items before type casting
500
+ def raw_pages
179
501
  if @key_fields_detector.key_present?
180
- pages_via_query
502
+ raw_pages_via_query
181
503
  else
182
504
  issue_scan_warning if Dynamoid::Config.warn_on_scan && query.present?
183
- pages_via_scan
505
+ raw_pages_via_scan
184
506
  end
185
507
  end
186
508
 
@@ -189,10 +511,12 @@ module Dynamoid
189
511
  # @return [Enumerator] an iterator of the found pages. An array of records
190
512
  #
191
513
  # @since 3.1.0
192
- def pages_via_query
193
- Enumerator.new do |yielder|
514
+ def raw_pages_via_query
515
+ Enumerator.new do |y|
194
516
  Dynamoid.adapter.query(source.table_name, range_query).each do |items, metadata|
195
- yielder.yield items.map { |hash| source.from_database(hash) }, metadata.slice(:last_evaluated_key)
517
+ options = metadata.slice(:last_evaluated_key)
518
+
519
+ y.yield items, options
196
520
  end
197
521
  end
198
522
  end
@@ -202,10 +526,12 @@ module Dynamoid
202
526
  # @return [Enumerator] an iterator of the found pages. An array of records
203
527
  #
204
528
  # @since 3.1.0
205
- def pages_via_scan
206
- Enumerator.new do |yielder|
529
+ def raw_pages_via_scan
530
+ Enumerator.new do |y|
207
531
  Dynamoid.adapter.scan(source.table_name, scan_query, scan_opts).each do |items, metadata|
208
- yielder.yield(items.map { |hash| source.from_database(hash) }, metadata.slice(:last_evaluated_key))
532
+ options = metadata.slice(:last_evaluated_key)
533
+
534
+ y.yield items, options
209
535
  end
210
536
  end
211
537
  end
@@ -271,6 +597,13 @@ module Dynamoid
271
597
  { contains: val }
272
598
  when 'not_contains'
273
599
  { not_contains: val }
600
+ # NULL/NOT_NULL operators don't have parameters
601
+ # So { null: true } means NULL check and { null: false } means NOT_NULL one
602
+ # The same logic is used for { not_null: BOOL }
603
+ when 'null'
604
+ val ? { null: nil } : { not_null: nil }
605
+ when 'not_null'
606
+ val ? { not_null: nil } : { null: nil }
274
607
  end
275
608
 
276
609
  { name.to_sym => hash }
@@ -282,6 +615,13 @@ module Dynamoid
282
615
 
283
616
  def range_query
284
617
  opts = {}
618
+ query = self.query
619
+
620
+ # Honor STI and :type field if it presents
621
+ if @source.attributes.key?(@source.inheritance_field) &&
622
+ @key_fields_detector.hash_key.to_sym != @source.inheritance_field.to_sym
623
+ query.update(sti_condition)
624
+ end
285
625
 
286
626
  # Add hash key
287
627
  opts[:hash_key] = @key_fields_detector.hash_key
@@ -289,15 +629,7 @@ module Dynamoid
289
629
 
290
630
  # Add range key
291
631
  if @key_fields_detector.range_key
292
- opts[:range_key] = @key_fields_detector.range_key
293
- if query[@key_fields_detector.range_key].present?
294
- value = type_cast_condition_parameter(@key_fields_detector.range_key, query[@key_fields_detector.range_key])
295
- opts.update(range_eq: value)
296
- end
297
-
298
- query.keys.select { |k| k.to_s =~ /^#{@key_fields_detector.range_key}\./ }.each do |key|
299
- opts.merge!(range_hash(key))
300
- end
632
+ add_range_key_to_range_query(query, opts)
301
633
  end
302
634
 
303
635
  (query.keys.map(&:to_sym) - [@key_fields_detector.hash_key.to_sym, @key_fields_detector.range_key.try(:to_sym)])
@@ -314,10 +646,27 @@ module Dynamoid
314
646
  opts.merge(query_opts).merge(consistent_opts)
315
647
  end
316
648
 
649
+ def add_range_key_to_range_query(query, opts)
650
+ opts[:range_key] = @key_fields_detector.range_key
651
+ if query[@key_fields_detector.range_key].present?
652
+ value = type_cast_condition_parameter(@key_fields_detector.range_key, query[@key_fields_detector.range_key])
653
+ opts.update(range_eq: value)
654
+ end
655
+
656
+ query.keys.select { |k| k.to_s =~ /^#{@key_fields_detector.range_key}\./ }.each do |key|
657
+ opts.merge!(range_hash(key))
658
+ end
659
+ end
660
+
661
+ # TODO: casting should be operator aware
662
+ # e.g. for NULL operator value should be boolean
663
+ # and isn't related to an attribute own type
317
664
  def type_cast_condition_parameter(key, value)
318
665
  return value if %i[array set].include?(source.attributes[key.to_sym][:type])
319
666
 
320
- if !value.respond_to?(:to_ary)
667
+ if [true, false].include?(value) # Support argument for null/not_null operators
668
+ value
669
+ elsif !value.respond_to?(:to_ary)
321
670
  options = source.attributes[key.to_sym]
322
671
  value_casted = TypeCasting.cast_field(value, options)
323
672
  Dumping.dump_field(value_casted, options)
@@ -356,17 +705,27 @@ module Dynamoid
356
705
 
357
706
  def query_opts
358
707
  opts = {}
708
+ # Don't specify select = ALL_ATTRIBUTES option explicitly because it's
709
+ # already a default value of Select statement. Explicite Select value
710
+ # conflicts with AttributesToGet statement (project option).
359
711
  opts[:index_name] = @key_fields_detector.index_name if @key_fields_detector.index_name
360
- opts[:select] = 'ALL_ATTRIBUTES'
361
712
  opts[:record_limit] = @record_limit if @record_limit
362
713
  opts[:scan_limit] = @scan_limit if @scan_limit
363
714
  opts[:batch_size] = @batch_size if @batch_size
364
715
  opts[:exclusive_start_key] = start_key if @start
365
716
  opts[:scan_index_forward] = @scan_index_forward
717
+ opts[:project] = @project
366
718
  opts
367
719
  end
368
720
 
369
721
  def scan_query
722
+ query = self.query
723
+
724
+ # Honor STI and :type field if it presents
725
+ if sti_condition
726
+ query.update(sti_condition)
727
+ end
728
+
370
729
  {}.tap do |opts|
371
730
  query.keys.map(&:to_sym).each do |key|
372
731
  if key.to_s.include?('.')
@@ -386,8 +745,21 @@ module Dynamoid
386
745
  opts[:batch_size] = @batch_size if @batch_size
387
746
  opts[:exclusive_start_key] = start_key if @start
388
747
  opts[:consistent_read] = true if @consistent_read
748
+ opts[:project] = @project
389
749
  opts
390
750
  end
751
+
752
+ def sti_condition
753
+ condition = {}
754
+ type = @source.inheritance_field
755
+
756
+ if @source.attributes.key?(type)
757
+ class_names = @source.deep_subclasses.map(&:name) << @source.name
758
+ condition[:"#{type}.in"] = class_names
759
+ end
760
+
761
+ condition
762
+ end
391
763
  end
392
764
  end
393
765
  end