dynamoid 3.10.0 → 3.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +37 -1
  3. data/README.md +268 -8
  4. data/dynamoid.gemspec +4 -4
  5. data/lib/dynamoid/adapter.rb +1 -1
  6. data/lib/dynamoid/adapter_plugin/aws_sdk_v3/filter_expression_convertor.rb +53 -18
  7. data/lib/dynamoid/adapter_plugin/aws_sdk_v3/item_updater.rb +5 -4
  8. data/lib/dynamoid/adapter_plugin/aws_sdk_v3/projection_expression_convertor.rb +9 -7
  9. data/lib/dynamoid/adapter_plugin/aws_sdk_v3/query.rb +1 -1
  10. data/lib/dynamoid/adapter_plugin/aws_sdk_v3/scan.rb +1 -1
  11. data/lib/dynamoid/adapter_plugin/aws_sdk_v3/transact.rb +31 -0
  12. data/lib/dynamoid/adapter_plugin/aws_sdk_v3.rb +17 -5
  13. data/lib/dynamoid/components.rb +1 -0
  14. data/lib/dynamoid/config.rb +3 -0
  15. data/lib/dynamoid/criteria/chain.rb +74 -21
  16. data/lib/dynamoid/criteria/where_conditions.rb +13 -6
  17. data/lib/dynamoid/dirty.rb +97 -11
  18. data/lib/dynamoid/dumping.rb +39 -17
  19. data/lib/dynamoid/errors.rb +30 -3
  20. data/lib/dynamoid/fields.rb +13 -3
  21. data/lib/dynamoid/finders.rb +44 -23
  22. data/lib/dynamoid/loadable.rb +1 -0
  23. data/lib/dynamoid/persistence/inc.rb +35 -19
  24. data/lib/dynamoid/persistence/item_updater_with_casting_and_dumping.rb +36 -0
  25. data/lib/dynamoid/persistence/item_updater_with_dumping.rb +33 -0
  26. data/lib/dynamoid/persistence/save.rb +29 -14
  27. data/lib/dynamoid/persistence/update_fields.rb +23 -8
  28. data/lib/dynamoid/persistence/update_validations.rb +3 -3
  29. data/lib/dynamoid/persistence/upsert.rb +22 -8
  30. data/lib/dynamoid/persistence.rb +184 -28
  31. data/lib/dynamoid/transaction_read/find.rb +137 -0
  32. data/lib/dynamoid/transaction_read.rb +146 -0
  33. data/lib/dynamoid/transaction_write/base.rb +47 -0
  34. data/lib/dynamoid/transaction_write/create.rb +49 -0
  35. data/lib/dynamoid/transaction_write/delete_with_instance.rb +65 -0
  36. data/lib/dynamoid/transaction_write/delete_with_primary_key.rb +64 -0
  37. data/lib/dynamoid/transaction_write/destroy.rb +84 -0
  38. data/lib/dynamoid/transaction_write/item_updater.rb +55 -0
  39. data/lib/dynamoid/transaction_write/save.rb +169 -0
  40. data/lib/dynamoid/transaction_write/update_attributes.rb +46 -0
  41. data/lib/dynamoid/transaction_write/update_fields.rb +239 -0
  42. data/lib/dynamoid/transaction_write/upsert.rb +106 -0
  43. data/lib/dynamoid/transaction_write.rb +673 -0
  44. data/lib/dynamoid/type_casting.rb +3 -1
  45. data/lib/dynamoid/undumping.rb +13 -2
  46. data/lib/dynamoid/validations.rb +8 -5
  47. data/lib/dynamoid/version.rb +1 -1
  48. data/lib/dynamoid.rb +8 -0
  49. metadata +21 -5
@@ -8,6 +8,7 @@ require_relative 'aws_sdk_v3/batch_get_item'
8
8
  require_relative 'aws_sdk_v3/item_updater'
9
9
  require_relative 'aws_sdk_v3/table'
10
10
  require_relative 'aws_sdk_v3/until_past_table_status'
11
+ require_relative 'aws_sdk_v3/transact'
11
12
 
12
13
  module Dynamoid
13
14
  # @private
@@ -289,6 +290,18 @@ module Dynamoid
289
290
  raise Dynamoid::Errors::ConditionalCheckFailedException, e
290
291
  end
291
292
 
293
+ def transact_write_items(items)
294
+ Transact.new(client).transact_write_items(items)
295
+ end
296
+
297
+ def transact_read_items(items)
298
+ request = {
299
+ transact_items: items,
300
+ return_consumed_capacity: 'TOTAL',
301
+ }
302
+ client.transact_get_items(request)
303
+ end
304
+
292
305
  # Create a table on DynamoDB. This usually takes a long time to complete.
293
306
  #
294
307
  # @param [String] table_name the name of the table to create
@@ -512,7 +525,7 @@ module Dynamoid
512
525
  # @since 1.0.0
513
526
  #
514
527
  # @todo Provide support for various other options http://docs.aws.amazon.com/sdkforruby/api/Aws/DynamoDB/Client.html#query-instance_method
515
- def query(table_name, key_conditions, non_key_conditions = {}, options = {})
528
+ def query(table_name, key_conditions, non_key_conditions = [], options = {})
516
529
  Enumerator.new do |yielder|
517
530
  table = describe_table(table_name)
518
531
 
@@ -545,7 +558,7 @@ module Dynamoid
545
558
  # @since 1.0.0
546
559
  #
547
560
  # @todo: Provide support for various options http://docs.aws.amazon.com/sdkforruby/api/Aws/DynamoDB/Client.html#scan-instance_method
548
- def scan(table_name, conditions = {}, options = {})
561
+ def scan(table_name, conditions = [], options = {})
549
562
  Enumerator.new do |yielder|
550
563
  table = describe_table(table_name)
551
564
 
@@ -558,7 +571,7 @@ module Dynamoid
558
571
  end
559
572
  end
560
573
 
561
- def scan_count(table_name, conditions = {}, options = {})
574
+ def scan_count(table_name, conditions = [], options = {})
562
575
  table = describe_table(table_name)
563
576
  options[:select] = 'COUNT'
564
577
 
@@ -662,8 +675,7 @@ module Dynamoid
662
675
  store_attribute_with_nil_value = config_value.nil? ? false : !!config_value
663
676
 
664
677
  attributes.reject do |_, v|
665
- ((v.is_a?(Set) || v.is_a?(String)) && v.empty?) ||
666
- (!store_attribute_with_nil_value && v.nil?)
678
+ !store_attribute_with_nil_value && v.nil?
667
679
  end.transform_values do |v|
668
680
  v.is_a?(Hash) ? v.stringify_keys : v
669
681
  end
@@ -13,6 +13,7 @@ module Dynamoid
13
13
 
14
14
  define_model_callbacks :create, :save, :destroy, :update
15
15
  define_model_callbacks :initialize, :find, :touch, only: :after
16
+ define_model_callbacks :commit, :rollback, only: :after
16
17
 
17
18
  before_save :set_expires_field
18
19
  after_initialize :set_inheritance_field
@@ -36,6 +36,7 @@ module Dynamoid
36
36
  option :read_capacity, default: 100
37
37
  option :write_capacity, default: 20
38
38
  option :warn_on_scan, default: true
39
+ option :error_on_scan, default: false
39
40
  option :endpoint, default: nil
40
41
  option :identity_map, default: false
41
42
  option :timestamps, default: true
@@ -48,7 +49,9 @@ module Dynamoid
48
49
  option :dynamodb_timezone, default: :utc # available values - :utc, :local, time zone name like "Hawaii"
49
50
  option :store_datetime_as_string, default: false # store Time fields in ISO 8601 string format
50
51
  option :store_date_as_string, default: false # store Date fields in ISO 8601 string format
52
+ option :store_empty_string_as_nil, default: true # store attribute's empty String value as null
51
53
  option :store_boolean_as_native, default: true
54
+ option :store_binary_as_native, default: false
52
55
  option :backoff, default: nil # callable object to handle exceeding of table throughput limit
53
56
  option :backoff_strategies, default: {
54
57
  constant: BackoffStrategies::ConstantBackoff,
@@ -95,20 +95,27 @@ module Dynamoid
95
95
  #
96
96
  # Internally +where+ performs either +Scan+ or +Query+ operation.
97
97
  #
98
+ # Conditions can be specified as an expression as well:
99
+ #
100
+ # Post.where('links_count = :v', v: 2)
101
+ #
102
+ # This way complex expressions can be constructed (e.g. with AND, OR, and NOT
103
+ # keyword):
104
+ #
105
+ # Address.where('city = :c AND (post_code = :pc1 OR post_code = :pc2)', city: 'A', pc1: '001', pc2: '002')
106
+ #
107
+ # See documentation for condition expression's syntax and examples:
108
+ # - https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Expressions.OperatorsAndFunctions.html
109
+ # - https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Query.FilterExpression.html
110
+ #
98
111
  # @return [Dynamoid::Criteria::Chain]
99
112
  # @since 0.2.0
100
- def where(args)
101
- detector = NonexistentFieldsDetector.new(args, @source)
102
- if detector.found?
103
- Dynamoid.logger.warn(detector.warning_message)
113
+ def where(conditions, placeholders = nil)
114
+ if conditions.is_a?(Hash)
115
+ where_with_hash(conditions)
116
+ else
117
+ where_with_string(conditions, placeholders)
104
118
  end
105
-
106
- @where_conditions.update(args.symbolize_keys)
107
-
108
- # we should re-initialize keys detector every time we change @where_conditions
109
- @key_fields_detector = KeyFieldsDetector.new(@where_conditions, @source, forced_index_name: @forced_index_name)
110
-
111
- self
112
119
  end
113
120
 
114
121
  # Turns on strongly consistent reads.
@@ -500,6 +507,29 @@ module Dynamoid
500
507
 
501
508
  private
502
509
 
510
+ def where_with_hash(conditions)
511
+ detector = NonexistentFieldsDetector.new(conditions, @source)
512
+ if detector.found?
513
+ Dynamoid.logger.warn(detector.warning_message)
514
+ end
515
+
516
+ @where_conditions.update_with_hash(conditions.symbolize_keys)
517
+
518
+ # we should re-initialize keys detector every time we change @where_conditions
519
+ @key_fields_detector = KeyFieldsDetector.new(@where_conditions, @source, forced_index_name: @forced_index_name)
520
+
521
+ self
522
+ end
523
+
524
+ def where_with_string(query, placeholders)
525
+ @where_conditions.update_with_string(query, placeholders)
526
+
527
+ # we should re-initialize keys detector every time we change @where_conditions
528
+ @key_fields_detector = KeyFieldsDetector.new(@where_conditions, @source, forced_index_name: @forced_index_name)
529
+
530
+ self
531
+ end
532
+
503
533
  # The actual records referenced by the association.
504
534
  #
505
535
  # @return [Enumerator] an iterator of the found records.
@@ -533,7 +563,7 @@ module Dynamoid
533
563
  if @key_fields_detector.key_present?
534
564
  raw_pages_via_query
535
565
  else
536
- issue_scan_warning if Dynamoid::Config.warn_on_scan && !@where_conditions.empty?
566
+ validate_scan_conditions
537
567
  raw_pages_via_scan
538
568
  end
539
569
  end
@@ -568,6 +598,12 @@ module Dynamoid
568
598
  end
569
599
  end
570
600
 
601
+ def validate_scan_conditions
602
+ raise Dynamoid::Errors::ScanProhibited if Dynamoid::Config.error_on_scan && !@where_conditions.empty?
603
+
604
+ issue_scan_warning if Dynamoid::Config.warn_on_scan && !@where_conditions.empty?
605
+ end
606
+
571
607
  def issue_scan_warning
572
608
  Dynamoid.logger.warn 'Queries without an index are forced to use scan and are generally much slower than indexed queries!'
573
609
  Dynamoid.logger.warn "You can index this query by adding index declaration to #{source.to_s.underscore}.rb:"
@@ -635,12 +671,12 @@ module Dynamoid
635
671
  end
636
672
 
637
673
  def query_non_key_conditions
638
- opts = {}
674
+ hash_conditions = {}
639
675
 
640
676
  # Honor STI and :type field if it presents
641
677
  if @source.attributes.key?(@source.inheritance_field) &&
642
678
  @key_fields_detector.hash_key.to_sym != @source.inheritance_field.to_sym
643
- @where_conditions.update(sti_condition)
679
+ @where_conditions.update_with_hash(sti_condition)
644
680
  end
645
681
 
646
682
  # TODO: Separate key conditions and non-key conditions properly:
@@ -650,22 +686,30 @@ module Dynamoid
650
686
  .reject { |k, _| k.to_s =~ /^#{@key_fields_detector.range_key}\./ }
651
687
  keys.each do |key|
652
688
  name, condition = field_condition(key, @where_conditions[key])
653
- opts[name] ||= []
654
- opts[name] << condition
689
+ hash_conditions[name] ||= []
690
+ hash_conditions[name] << condition
655
691
  end
656
692
 
657
- opts
693
+ string_conditions = []
694
+ @where_conditions.string_conditions.each do |query, placeholders|
695
+ placeholders ||= {}
696
+ string_conditions << [query, placeholders]
697
+ end
698
+
699
+ [hash_conditions] + string_conditions
658
700
  end
659
701
 
660
702
  # TODO: casting should be operator aware
661
703
  # e.g. for NULL operator value should be boolean
662
704
  # and isn't related to an attribute own type
663
705
  def type_cast_condition_parameter(key, value)
664
- return value if %i[array set].include?(source.attributes[key.to_sym][:type])
706
+ field_type = source.attributes[key.to_sym][:type]
707
+
708
+ return value if %i[array set].include?(field_type)
665
709
 
666
710
  if [true, false].include?(value) # Support argument for null/not_null operators
667
711
  value
668
- elsif !value.respond_to?(:to_ary)
712
+ elsif !value.respond_to?(:to_ary) || field_type == :serialized
669
713
  options = source.attributes[key.to_sym]
670
714
  value_casted = TypeCasting.cast_field(value, options)
671
715
  Dumping.dump_field(value_casted, options)
@@ -721,16 +765,25 @@ module Dynamoid
721
765
  def scan_conditions
722
766
  # Honor STI and :type field if it presents
723
767
  if sti_condition
724
- @where_conditions.update(sti_condition)
768
+ @where_conditions.update_with_hash(sti_condition)
725
769
  end
726
770
 
727
- {}.tap do |opts|
771
+ hash_conditions = {}
772
+ hash_conditions.tap do |opts|
728
773
  @where_conditions.keys.map(&:to_sym).each do |key|
729
774
  name, condition = field_condition(key, @where_conditions[key])
730
775
  opts[name] ||= []
731
776
  opts[name] << condition
732
777
  end
733
778
  end
779
+
780
+ string_conditions = []
781
+ @where_conditions.string_conditions.each do |query, placeholders|
782
+ placeholders ||= {}
783
+ string_conditions << [query, placeholders]
784
+ end
785
+
786
+ [hash_conditions] + string_conditions
734
787
  end
735
788
 
736
789
  def scan_options
@@ -4,24 +4,31 @@ module Dynamoid
4
4
  module Criteria
5
5
  # @private
6
6
  class WhereConditions
7
+ attr_reader :string_conditions
8
+
7
9
  def initialize
8
- @conditions = []
10
+ @hash_conditions = []
11
+ @string_conditions = []
12
+ end
13
+
14
+ def update_with_hash(hash)
15
+ @hash_conditions << hash.symbolize_keys
9
16
  end
10
17
 
11
- def update(hash)
12
- @conditions << hash.symbolize_keys
18
+ def update_with_string(query, placeholders)
19
+ @string_conditions << [query, placeholders]
13
20
  end
14
21
 
15
22
  def keys
16
- @conditions.flat_map(&:keys)
23
+ @hash_conditions.flat_map(&:keys)
17
24
  end
18
25
 
19
26
  def empty?
20
- @conditions.empty?
27
+ @hash_conditions.empty? && @string_conditions.empty?
21
28
  end
22
29
 
23
30
  def [](key)
24
- hash = @conditions.find { |h| h.key?(key) }
31
+ hash = @hash_conditions.find { |h| h.key?(key) }
25
32
  hash[key] if hash
26
33
  end
27
34
  end
@@ -36,8 +36,11 @@ module Dynamoid
36
36
  end
37
37
  end
38
38
 
39
- def from_database(*)
40
- super.tap(&:clear_changes_information)
39
+ def from_database(attributes_from_database)
40
+ super.tap do |model|
41
+ model.clear_changes_information
42
+ model.assign_attributes_from_database(DeepDupper.dup_attributes(model.attributes, model.class))
43
+ end
41
44
  end
42
45
  end
43
46
 
@@ -108,7 +111,7 @@ module Dynamoid
108
111
  #
109
112
  # @return [ActiveSupport::HashWithIndifferentAccess]
110
113
  def changes
111
- ActiveSupport::HashWithIndifferentAccess[changed.map { |name| [name, attribute_change(name)] }]
114
+ ActiveSupport::HashWithIndifferentAccess[changed_attributes.map { |name, old_value| [name, [old_value, read_attribute(name)]] }]
112
115
  end
113
116
 
114
117
  # Returns a hash of attributes that were changed before the model was saved.
@@ -132,19 +135,21 @@ module Dynamoid
132
135
  #
133
136
  # @return [ActiveSupport::HashWithIndifferentAccess]
134
137
  def changed_attributes
135
- @changed_attributes ||= ActiveSupport::HashWithIndifferentAccess.new
138
+ attributes_changed_by_setter.merge(attributes_changed_in_place)
136
139
  end
137
140
 
138
141
  # Clear all dirty data: current changes and previous changes.
139
142
  def clear_changes_information
140
143
  @previously_changed = ActiveSupport::HashWithIndifferentAccess.new
141
- @changed_attributes = ActiveSupport::HashWithIndifferentAccess.new
144
+ @attributes_changed_by_setter = ActiveSupport::HashWithIndifferentAccess.new
145
+ @attributes_from_database = HashWithIndifferentAccess.new(DeepDupper.dup_attributes(@attributes, self.class))
142
146
  end
143
147
 
144
148
  # Clears dirty data and moves +changes+ to +previous_changes+.
145
149
  def changes_applied
146
150
  @previously_changed = changes
147
- @changed_attributes = ActiveSupport::HashWithIndifferentAccess.new
151
+ @attributes_changed_by_setter = ActiveSupport::HashWithIndifferentAccess.new
152
+ @attributes_from_database = HashWithIndifferentAccess.new(DeepDupper.dup_attributes(@attributes, self.class))
148
153
  end
149
154
 
150
155
  # Remove changes information for the provided attributes.
@@ -152,6 +157,9 @@ module Dynamoid
152
157
  # @param attributes [Array[String]] - a list of attributes to clear changes for
153
158
  def clear_attribute_changes(names)
154
159
  attributes_changed_by_setter.except!(*names)
160
+
161
+ slice = HashWithIndifferentAccess.new(@attributes).slice(*names)
162
+ attributes_from_database.merge!(DeepDupper.dup_attributes(slice, self.class))
155
163
  end
156
164
 
157
165
  # Handle <tt>*_changed?</tt> for +method_missing+.
@@ -220,12 +228,16 @@ module Dynamoid
220
228
  previous_changes[name] if attribute_previously_changed?(name)
221
229
  end
222
230
 
231
+ # @private
232
+ def assign_attributes_from_database(attributes_from_database)
233
+ @attributes_from_database = HashWithIndifferentAccess.new(attributes_from_database)
234
+ end
235
+
223
236
  private
224
237
 
225
238
  def changes_include?(name)
226
- attributes_changed_by_setter.include?(name)
239
+ attribute_changed_by_setter?(name) || attribute_changed_in_place?(name)
227
240
  end
228
- alias attribute_changed_by_setter? changes_include?
229
241
 
230
242
  # Handle <tt>*_change</tt> for +method_missing+.
231
243
  def attribute_change(name)
@@ -259,13 +271,87 @@ module Dynamoid
259
271
  previous_changes.include?(name)
260
272
  end
261
273
 
262
- # This is necessary because `changed_attributes` might be overridden in
263
- # other implemntations (e.g. in `ActiveRecord`)
264
- alias attributes_changed_by_setter changed_attributes
274
+ def attributes_changed_by_setter
275
+ @attributes_changed_by_setter ||= ActiveSupport::HashWithIndifferentAccess.new
276
+ end
277
+
278
+ def attribute_changed_by_setter?(name)
279
+ attributes_changed_by_setter.include?(name)
280
+ end
281
+
282
+ def attributes_from_database
283
+ @attributes_from_database ||= ActiveSupport::HashWithIndifferentAccess.new
284
+ end
265
285
 
266
286
  # Force an attribute to have a particular "before" value
267
287
  def set_attribute_was(name, old_value)
268
288
  attributes_changed_by_setter[name] = old_value
269
289
  end
290
+
291
+ def attributes_changed_in_place
292
+ attributes_from_database.select do |name, _|
293
+ attribute_changed_in_place?(name)
294
+ end
295
+ end
296
+
297
+ def attribute_changed_in_place?(name)
298
+ return false if attribute_changed_by_setter?(name)
299
+
300
+ value_from_database = attributes_from_database[name]
301
+ return false if value_from_database.nil?
302
+
303
+ value = read_attribute(name)
304
+ type_options = self.class.attributes[name.to_sym]
305
+
306
+ unless type_options[:type].is_a?(Class) && !type_options[:comparable]
307
+ # common case
308
+ value != value_from_database
309
+ else
310
+ # objects of a custom type that does not implement its own `#==` method
311
+ # (that's declared by `comparable: false` or just not specifying the
312
+ # option `comparable`) are compared by comparing their dumps
313
+ dump = Dumping.dump_field(value, type_options)
314
+ dump_from_database = Dumping.dump_field(value_from_database, type_options)
315
+ dump != dump_from_database
316
+ end
317
+ end
318
+
319
+ module DeepDupper
320
+ def self.dup_attributes(attributes, klass)
321
+ attributes.map do |name, value|
322
+ type_options = klass.attributes[name.to_sym]
323
+ value_duplicate = dup_attribute(value, type_options)
324
+ [name, value_duplicate]
325
+ end.to_h
326
+ end
327
+
328
+ def self.dup_attribute(value, type_options)
329
+ type, of = type_options.values_at(:type, :of)
330
+
331
+ case value
332
+ when NilClass, TrueClass, FalseClass, Numeric, Symbol, IO
333
+ # Till Ruby 2.4 these immutable objects could not be duplicated.
334
+ # IO objects (used for the binary type) cannot be duplicated as well.
335
+ value
336
+ when Array
337
+ if of.is_a? Class
338
+ # custom type
339
+ value.map { |e| dup_attribute(e, type: of) }
340
+ else
341
+ value.deep_dup
342
+ end
343
+ when Set
344
+ Set.new(value.map { |e| dup_attribute(e, type: of) })
345
+ else
346
+ if type.is_a? Class
347
+ # custom type
348
+ dump = Dumping.dump_field(value, type_options)
349
+ Undumping.undump_field(dump.deep_dup, type_options)
350
+ else
351
+ value.deep_dup
352
+ end
353
+ end
354
+ end
355
+ end
270
356
  end
271
357
  end
@@ -70,7 +70,8 @@ module Dynamoid
70
70
  end
71
71
 
72
72
  def invalid_value?(value)
73
- (value.is_a?(Set) || value.is_a?(String)) && value.empty?
73
+ (value.is_a?(Set) && value.empty?) ||
74
+ (value.is_a?(String) && value.empty? && Config.store_empty_string_as_nil)
74
75
  end
75
76
  end
76
77
 
@@ -86,6 +87,12 @@ module Dynamoid
86
87
 
87
88
  # string -> string
88
89
  class StringDumper < Base
90
+ def process(string)
91
+ return nil if string.nil?
92
+ return nil if string.empty? && Config.store_empty_string_as_nil
93
+
94
+ string
95
+ end
89
96
  end
90
97
 
91
98
  # integer -> number
@@ -101,6 +108,8 @@ module Dynamoid
101
108
  ALLOWED_TYPES = %i[string integer number date datetime serialized].freeze
102
109
 
103
110
  def process(set)
111
+ return nil if set.is_a?(Set) && set.empty?
112
+
104
113
  if @options.key?(:of)
105
114
  process_typed_collection(set)
106
115
  else
@@ -112,13 +121,13 @@ module Dynamoid
112
121
 
113
122
  def process_typed_collection(set)
114
123
  if allowed_type?
115
- dumper = Dumping.find_dumper(element_options)
116
- result = set.map { |el| dumper.process(el) }
117
-
118
- if element_type == :string
119
- result.reject!(&:empty?)
124
+ # StringDumper may replace "" with nil so we cannot distinguish it from an explicit nil
125
+ if element_type == :string && Config.store_empty_string_as_nil
126
+ set.reject! { |s| s && s.empty? }
120
127
  end
121
128
 
129
+ dumper = Dumping.find_dumper(element_options)
130
+ result = set.map { |el| dumper.process(el) }
122
131
  result.to_set
123
132
  else
124
133
  raise ArgumentError, "Set element type #{element_type} isn't supported"
@@ -164,14 +173,13 @@ module Dynamoid
164
173
 
165
174
  def process_typed_collection(array)
166
175
  if allowed_type?
167
- dumper = Dumping.find_dumper(element_options)
168
- result = array.map { |el| dumper.process(el) }
169
-
170
- if element_type == :string
171
- result.reject!(&:empty?)
176
+ # StringDumper may replace "" with nil so we cannot distinguish it from an explicit nil
177
+ if element_type == :string && Config.store_empty_string_as_nil
178
+ array.reject! { |s| s && s.empty? }
172
179
  end
173
180
 
174
- result
181
+ dumper = Dumping.find_dumper(element_options)
182
+ array.map { |el| dumper.process(el) }
175
183
  else
176
184
  raise ArgumentError, "Array element type #{element_type} isn't supported"
177
185
  end
@@ -289,10 +297,24 @@ module Dynamoid
289
297
  end
290
298
  end
291
299
 
292
- # string -> string
300
+ # string -> StringIO
293
301
  class BinaryDumper < Base
294
302
  def process(value)
295
- Base64.strict_encode64(value)
303
+ store_as_binary = if @options[:store_as_native_binary].nil?
304
+ Dynamoid.config.store_binary_as_native
305
+ else
306
+ @options[:store_as_native_binary]
307
+ end
308
+
309
+ if store_as_binary
310
+ if value.is_a?(StringIO) || value.is_a?(IO)
311
+ value
312
+ else
313
+ StringIO.new(value)
314
+ end
315
+ else
316
+ Base64.strict_encode64(value)
317
+ end
296
318
  end
297
319
  end
298
320
 
@@ -301,10 +323,10 @@ module Dynamoid
301
323
  def process(value)
302
324
  field_class = @options[:type]
303
325
 
304
- if value.respond_to?(:dynamoid_dump)
305
- value.dynamoid_dump
306
- elsif field_class.respond_to?(:dynamoid_dump)
326
+ if field_class.respond_to?(:dynamoid_dump)
307
327
  field_class.dynamoid_dump(value)
328
+ elsif value.respond_to?(:dynamoid_dump)
329
+ value.dynamoid_dump
308
330
  else
309
331
  raise ArgumentError, "Neither #{field_class} nor #{value} supports serialization for Dynamoid."
310
332
  end
@@ -6,6 +6,7 @@ module Dynamoid
6
6
  # Generic Dynamoid error
7
7
  class Error < StandardError; end
8
8
 
9
+ class MissingHashKey < Error; end
9
10
  class MissingRangeKey < Error; end
10
11
 
11
12
  class MissingIndex < Error; end
@@ -15,18 +16,27 @@ module Dynamoid
15
16
  class InvalidIndex < Error
16
17
  def initialize(item)
17
18
  if item.is_a? String
18
- super(item)
19
+ super
19
20
  else
20
21
  super("Validation failed: #{item.errors.full_messages.join(', ')}")
21
22
  end
22
23
  end
23
24
  end
24
25
 
26
+ class RecordNotSaved < Error
27
+ attr_reader :record
28
+
29
+ def initialize(record)
30
+ super('Failed to save the item')
31
+ @record = record
32
+ end
33
+ end
34
+
25
35
  class RecordNotDestroyed < Error
26
36
  attr_reader :record
27
37
 
28
38
  def initialize(record)
29
- super('Failed to destroy item')
39
+ super('Failed to destroy the item')
30
40
  @record = record
31
41
  end
32
42
  end
@@ -76,8 +86,25 @@ module Dynamoid
76
86
 
77
87
  class UnsupportedKeyType < Error; end
78
88
 
79
- class UnknownAttribute < Error; end
89
+ class UnknownAttribute < Error
90
+ attr_reader :model_class, :attribute_name
91
+
92
+ def initialize(model_class, attribute_name)
93
+ super("Attribute #{attribute_name} does not exist in #{model_class}")
94
+
95
+ @model_class = model_class
96
+ @attribute_name = attribute_name
97
+ end
98
+ end
80
99
 
81
100
  class SubclassNotFound < Error; end
101
+
102
+ class Rollback < Error; end
103
+
104
+ class ScanProhibited < Error
105
+ def initialize(_msg = nil)
106
+ super('Scan operations prohibited. Modify Dynamoid::Config.error_on_scan to change this behavior.')
107
+ end
108
+ end
82
109
  end
83
110
  end
@@ -197,9 +197,19 @@ module Dynamoid
197
197
  # field :id, :integer
198
198
  # end
199
199
  #
200
+ # To declare a new attribute with not-default type as a table hash key a
201
+ # :key_type option can be used:
202
+ #
203
+ # class User
204
+ # include Dynamoid::Document
205
+ #
206
+ # table key: :user_id, key_type: :integer
207
+ # end
208
+ #
200
209
  # @param options [Hash] options to override default table settings
201
210
  # @option options [Symbol] :name name of a table
202
211
  # @option options [Symbol] :key name of a hash key attribute
212
+ # @option options [Symbol] :key_type type of a hash key attribute
203
213
  # @option options [Symbol] :inheritance_field name of an attribute used for STI
204
214
  # @option options [Symbol] :capacity_mode table billing mode - either +provisioned+ or +on_demand+
205
215
  # @option options [Integer] :write_capacity table write capacity units
@@ -210,11 +220,11 @@ module Dynamoid
210
220
  # @since 0.4.0
211
221
  def table(options)
212
222
  self.options = options
213
-
214
223
  # a default 'id' column is created when Dynamoid::Document is included
215
224
  unless attributes.key? hash_key
216
225
  remove_field :id
217
- field(hash_key)
226
+ key_type = options[:key_type] || :string
227
+ field(hash_key, key_type)
218
228
  end
219
229
 
220
230
  # The created_at/updated_at fields are declared in the `included` callback first.
@@ -291,7 +301,7 @@ module Dynamoid
291
301
  old_value = read_attribute(name)
292
302
 
293
303
  unless attribute_is_present_on_model?(name)
294
- raise Dynamoid::Errors::UnknownAttribute, "Attribute #{name} is not part of the model"
304
+ raise Dynamoid::Errors::UnknownAttribute.new(self.class, name)
295
305
  end
296
306
 
297
307
  if association = @associations[name]