dynamoid 3.8.0 → 3.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +54 -3
  3. data/README.md +111 -60
  4. data/SECURITY.md +17 -0
  5. data/dynamoid.gemspec +65 -0
  6. data/lib/dynamoid/adapter.rb +20 -13
  7. data/lib/dynamoid/adapter_plugin/aws_sdk_v3/create_table.rb +2 -2
  8. data/lib/dynamoid/adapter_plugin/aws_sdk_v3/execute_statement.rb +62 -0
  9. data/lib/dynamoid/adapter_plugin/aws_sdk_v3/filter_expression_convertor.rb +78 -0
  10. data/lib/dynamoid/adapter_plugin/aws_sdk_v3/item_updater.rb +28 -2
  11. data/lib/dynamoid/adapter_plugin/aws_sdk_v3/middleware/limit.rb +3 -0
  12. data/lib/dynamoid/adapter_plugin/aws_sdk_v3/projection_expression_convertor.rb +38 -0
  13. data/lib/dynamoid/adapter_plugin/aws_sdk_v3/query.rb +46 -61
  14. data/lib/dynamoid/adapter_plugin/aws_sdk_v3/scan.rb +33 -27
  15. data/lib/dynamoid/adapter_plugin/aws_sdk_v3.rb +116 -70
  16. data/lib/dynamoid/associations/belongs_to.rb +6 -6
  17. data/lib/dynamoid/associations.rb +1 -1
  18. data/lib/dynamoid/components.rb +2 -3
  19. data/lib/dynamoid/config/options.rb +12 -12
  20. data/lib/dynamoid/config.rb +1 -0
  21. data/lib/dynamoid/criteria/chain.rb +101 -138
  22. data/lib/dynamoid/criteria/key_fields_detector.rb +6 -7
  23. data/lib/dynamoid/criteria/nonexistent_fields_detector.rb +2 -2
  24. data/lib/dynamoid/criteria/where_conditions.rb +29 -0
  25. data/lib/dynamoid/dirty.rb +57 -57
  26. data/lib/dynamoid/document.rb +39 -3
  27. data/lib/dynamoid/dumping.rb +2 -2
  28. data/lib/dynamoid/errors.rb +2 -0
  29. data/lib/dynamoid/fields/declare.rb +6 -6
  30. data/lib/dynamoid/fields.rb +9 -27
  31. data/lib/dynamoid/finders.rb +26 -30
  32. data/lib/dynamoid/indexes.rb +7 -10
  33. data/lib/dynamoid/loadable.rb +2 -2
  34. data/lib/dynamoid/log/formatter.rb +19 -4
  35. data/lib/dynamoid/persistence/import.rb +4 -1
  36. data/lib/dynamoid/persistence/inc.rb +66 -0
  37. data/lib/dynamoid/persistence/save.rb +55 -12
  38. data/lib/dynamoid/persistence/update_fields.rb +2 -2
  39. data/lib/dynamoid/persistence/update_validations.rb +2 -2
  40. data/lib/dynamoid/persistence.rb +128 -48
  41. data/lib/dynamoid/type_casting.rb +15 -14
  42. data/lib/dynamoid/undumping.rb +1 -1
  43. data/lib/dynamoid/version.rb +1 -1
  44. metadata +27 -49
  45. data/lib/dynamoid/criteria/ignored_conditions_detector.rb +0 -41
  46. data/lib/dynamoid/criteria/overwritten_conditions_detector.rb +0 -40
@@ -1,29 +1,35 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative 'key_fields_detector'
4
- require_relative 'ignored_conditions_detector'
5
- require_relative 'overwritten_conditions_detector'
6
4
  require_relative 'nonexistent_fields_detector'
5
+ require_relative 'where_conditions'
7
6
 
8
7
  module Dynamoid
9
8
  module Criteria
10
9
  # The criteria chain is equivalent to an ActiveRecord relation (and realistically I should change the name from
11
10
  # chain to relation). It is a chainable object that builds up a query and eventually executes it by a Query or Scan.
12
11
  class Chain
13
- attr_reader :query, :source, :consistent_read, :key_fields_detector
12
+ attr_reader :source, :consistent_read, :key_fields_detector
14
13
 
15
14
  include Enumerable
15
+
16
+ ALLOWED_FIELD_OPERATORS = Set.new(
17
+ %w[
18
+ eq ne gt lt gte lte between begins_with in contains not_contains null not_null
19
+ ]
20
+ ).freeze
21
+
16
22
  # Create a new criteria chain.
17
23
  #
18
24
  # @param [Class] source the class upon which the ultimate query will be performed.
19
25
  def initialize(source)
20
- @query = {}
26
+ @where_conditions = WhereConditions.new
21
27
  @source = source
22
28
  @consistent_read = false
23
29
  @scan_index_forward = true
24
30
 
25
- # we should re-initialize keys detector every time we change query
26
- @key_fields_detector = KeyFieldsDetector.new(@query, @source)
31
+ # we should re-initialize keys detector every time we change @where_conditions
32
+ @key_fields_detector = KeyFieldsDetector.new(@where_conditions, @source)
27
33
  end
28
34
 
29
35
  # Returns a chain which is a result of filtering current chain with the specified conditions.
@@ -92,25 +98,15 @@ module Dynamoid
92
98
  # @return [Dynamoid::Criteria::Chain]
93
99
  # @since 0.2.0
94
100
  def where(args)
95
- detector = IgnoredConditionsDetector.new(args)
96
- if detector.found?
97
- Dynamoid.logger.warn(detector.warning_message)
98
- end
99
-
100
- detector = OverwrittenConditionsDetector.new(@query, args)
101
- if detector.found?
102
- Dynamoid.logger.warn(detector.warning_message)
103
- end
104
-
105
101
  detector = NonexistentFieldsDetector.new(args, @source)
106
102
  if detector.found?
107
103
  Dynamoid.logger.warn(detector.warning_message)
108
104
  end
109
105
 
110
- query.update(args.symbolize_keys)
106
+ @where_conditions.update(args.symbolize_keys)
111
107
 
112
- # we should re-initialize keys detector every time we change query
113
- @key_fields_detector = KeyFieldsDetector.new(@query, @source, forced_index_name: @forced_index_name)
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)
114
110
 
115
111
  self
116
112
  end
@@ -187,10 +183,10 @@ module Dynamoid
187
183
  def first(*args)
188
184
  n = args.first || 1
189
185
 
190
- return self.dup.scan_limit(n).to_a.first(*args) if @query.blank?
186
+ return dup.scan_limit(n).to_a.first(*args) if @where_conditions.empty?
191
187
  return super if @key_fields_detector.non_key_present?
192
188
 
193
- self.dup.record_limit(n).to_a.first(*args)
189
+ dup.record_limit(n).to_a.first(*args)
194
190
  end
195
191
 
196
192
  # Returns the last item matching the criteria.
@@ -230,12 +226,12 @@ module Dynamoid
230
226
  ranges = []
231
227
 
232
228
  if @key_fields_detector.key_present?
233
- Dynamoid.adapter.query(source.table_name, range_query).flat_map { |i| i }.collect do |hash|
229
+ Dynamoid.adapter.query(source.table_name, query_key_conditions, query_non_key_conditions, query_options).flat_map { |i| i }.collect do |hash|
234
230
  ids << hash[source.hash_key.to_sym]
235
231
  ranges << hash[source.range_key.to_sym] if source.range_key
236
232
  end
237
233
  else
238
- Dynamoid.adapter.scan(source.table_name, scan_query, scan_opts).flat_map { |i| i }.collect do |hash|
234
+ Dynamoid.adapter.scan(source.table_name, scan_conditions, scan_options).flat_map { |i| i }.collect do |hash|
239
235
  ids << hash[source.hash_key.to_sym]
240
236
  ranges << hash[source.range_key.to_sym] if source.range_key
241
237
  end
@@ -384,7 +380,7 @@ module Dynamoid
384
380
  raise Dynamoid::Errors::InvalidIndex, "Unknown index #{index_name}" unless @source.find_index_by_name(index_name)
385
381
 
386
382
  @forced_index_name = index_name
387
- @key_fields_detector = KeyFieldsDetector.new(@query, @source, forced_index_name: index_name)
383
+ @key_fields_detector = KeyFieldsDetector.new(@where_conditions, @source, forced_index_name: index_name)
388
384
  self
389
385
  end
390
386
 
@@ -451,9 +447,9 @@ module Dynamoid
451
447
  # It takes one or more field names and returns a collection of models with only
452
448
  # these fields set.
453
449
  #
454
- # Post.where('views_count.gt' => 1000).select(:title)
455
- # Post.where('views_count.gt' => 1000).select(:title, :created_at)
456
- # Post.select(:id)
450
+ # Post.where('views_count.gt' => 1000).project(:title)
451
+ # Post.where('views_count.gt' => 1000).project(:title, :created_at)
452
+ # Post.project(:id)
457
453
  #
458
454
  # It can be used to avoid loading large field values and to decrease a
459
455
  # memory footprint.
@@ -487,7 +483,9 @@ module Dynamoid
487
483
  def pluck(*args)
488
484
  fields = args.map(&:to_sym)
489
485
 
490
- scope = self.dup
486
+ # `project` has a side effect - it sets `@project` instance variable.
487
+ # So use a duplicate to not pollute original chain.
488
+ scope = dup
491
489
  scope.project(*fields)
492
490
 
493
491
  if fields.many?
@@ -525,6 +523,7 @@ module Dynamoid
525
523
  def pages
526
524
  raw_pages.lazy.map do |items, options|
527
525
  models = items.map { |i| source.from_database(i) }
526
+ models.each { |m| m.run_callbacks :find }
528
527
  [models, options]
529
528
  end.each
530
529
  end
@@ -534,7 +533,7 @@ module Dynamoid
534
533
  if @key_fields_detector.key_present?
535
534
  raw_pages_via_query
536
535
  else
537
- issue_scan_warning if Dynamoid::Config.warn_on_scan && query.present?
536
+ issue_scan_warning if Dynamoid::Config.warn_on_scan && !@where_conditions.empty?
538
537
  raw_pages_via_scan
539
538
  end
540
539
  end
@@ -546,7 +545,7 @@ module Dynamoid
546
545
  # @since 3.1.0
547
546
  def raw_pages_via_query
548
547
  Enumerator.new do |y|
549
- Dynamoid.adapter.query(source.table_name, range_query).each do |items, metadata|
548
+ Dynamoid.adapter.query(source.table_name, query_key_conditions, query_non_key_conditions, query_options).each do |items, metadata|
550
549
  options = metadata.slice(:last_evaluated_key)
551
550
 
552
551
  y.yield items, options
@@ -561,7 +560,7 @@ module Dynamoid
561
560
  # @since 3.1.0
562
561
  def raw_pages_via_scan
563
562
  Enumerator.new do |y|
564
- Dynamoid.adapter.scan(source.table_name, scan_query, scan_opts).each do |items, metadata|
563
+ Dynamoid.adapter.scan(source.table_name, scan_conditions, scan_options).each do |items, metadata|
565
564
  options = metadata.slice(:last_evaluated_key)
566
565
 
567
566
  y.yield items, options
@@ -574,121 +573,88 @@ module Dynamoid
574
573
  Dynamoid.logger.warn "You can index this query by adding index declaration to #{source.to_s.underscore}.rb:"
575
574
  Dynamoid.logger.warn "* global_secondary_index hash_key: 'some-name', range_key: 'some-another-name'"
576
575
  Dynamoid.logger.warn "* local_secondary_index range_key: 'some-name'"
577
- Dynamoid.logger.warn "Not indexed attributes: #{query.keys.sort.collect { |name| ":#{name}" }.join(', ')}"
576
+ Dynamoid.logger.warn "Not indexed attributes: #{@where_conditions.keys.sort.collect { |name| ":#{name}" }.join(', ')}"
578
577
  end
579
578
 
580
579
  def count_via_query
581
- Dynamoid.adapter.query_count(source.table_name, range_query)
580
+ Dynamoid.adapter.query_count(source.table_name, query_key_conditions, query_non_key_conditions, query_options)
582
581
  end
583
582
 
584
583
  def count_via_scan
585
- Dynamoid.adapter.scan_count(source.table_name, scan_query, scan_opts)
586
- end
587
-
588
- def range_hash(key)
589
- name, operation = key.to_s.split('.')
590
- val = type_cast_condition_parameter(name, query[key])
591
-
592
- case operation
593
- when 'gt'
594
- { range_greater_than: val }
595
- when 'lt'
596
- { range_less_than: val }
597
- when 'gte'
598
- { range_gte: val }
599
- when 'lte'
600
- { range_lte: val }
601
- when 'between'
602
- { range_between: val }
603
- when 'begins_with'
604
- { range_begins_with: val }
605
- end
584
+ Dynamoid.adapter.scan_count(source.table_name, scan_conditions, scan_options)
606
585
  end
607
586
 
608
- def field_hash(key)
609
- name, operation = key.to_s.split('.')
610
- val = type_cast_condition_parameter(name, query[key])
611
-
612
- hash = case operation
613
- when 'ne'
614
- { ne: val }
615
- when 'gt'
616
- { gt: val }
617
- when 'lt'
618
- { lt: val }
619
- when 'gte'
620
- { gte: val }
621
- when 'lte'
622
- { lte: val }
623
- when 'between'
624
- { between: val }
625
- when 'begins_with'
626
- { begins_with: val }
627
- when 'in'
628
- { in: val }
629
- when 'contains'
630
- { contains: val }
631
- when 'not_contains'
632
- { not_contains: val }
633
- # NULL/NOT_NULL operators don't have parameters
634
- # So { null: true } means NULL check and { null: false } means NOT_NULL one
635
- # The same logic is used for { not_null: BOOL }
636
- when 'null'
637
- val ? { null: nil } : { not_null: nil }
638
- when 'not_null'
639
- val ? { not_null: nil } : { null: nil }
640
- end
641
-
642
- { name.to_sym => hash }
643
- end
644
-
645
- def consistent_opts
646
- { consistent_read: consistent_read }
647
- end
648
-
649
- def range_query
650
- opts = {}
651
- query = self.query
587
+ def field_condition(key, value_before_type_casting)
588
+ name, operator = key.to_s.split('.')
589
+ value = type_cast_condition_parameter(name, value_before_type_casting)
590
+ operator ||= 'eq'
652
591
 
653
- # Honor STI and :type field if it presents
654
- if @source.attributes.key?(@source.inheritance_field) &&
655
- @key_fields_detector.hash_key.to_sym != @source.inheritance_field.to_sym
656
- query.update(sti_condition)
592
+ unless operator.in? ALLOWED_FIELD_OPERATORS
593
+ raise Dynamoid::Errors::Error, "Unsupported operator #{operator} in #{key}"
657
594
  end
658
595
 
596
+ condition =
597
+ case operator
598
+ # NULL/NOT_NULL operators don't have parameters
599
+ # So { null: true } means NULL check and { null: false } means NOT_NULL one
600
+ # The same logic is used for { not_null: BOOL }
601
+ when 'null'
602
+ value ? [:null, nil] : [:not_null, nil]
603
+ when 'not_null'
604
+ value ? [:not_null, nil] : [:null, nil]
605
+ else
606
+ [operator.to_sym, value]
607
+ end
608
+
609
+ [name.to_sym, condition]
610
+ end
611
+
612
+ def query_key_conditions
613
+ opts = {}
614
+
659
615
  # Add hash key
660
- opts[:hash_key] = @key_fields_detector.hash_key
661
- opts[:hash_value] = type_cast_condition_parameter(@key_fields_detector.hash_key, query[@key_fields_detector.hash_key])
616
+ # TODO: always have hash key in @where_conditions?
617
+ _, condition = field_condition(@key_fields_detector.hash_key, @where_conditions[@key_fields_detector.hash_key])
618
+ opts[@key_fields_detector.hash_key] = [condition]
662
619
 
663
620
  # Add range key
664
621
  if @key_fields_detector.range_key
665
- add_range_key_to_range_query(query, opts)
666
- end
622
+ if @where_conditions[@key_fields_detector.range_key].present?
623
+ _, condition = field_condition(@key_fields_detector.range_key, @where_conditions[@key_fields_detector.range_key])
624
+ opts[@key_fields_detector.range_key] = [condition]
625
+ end
667
626
 
668
- (query.keys.map(&:to_sym) - [@key_fields_detector.hash_key.to_sym, @key_fields_detector.range_key.try(:to_sym)])
669
- .reject { |k, _| k.to_s =~ /^#{@key_fields_detector.range_key}\./ }
670
- .each do |key|
671
- if key.to_s.include?('.')
672
- opts.update(field_hash(key))
673
- else
674
- value = type_cast_condition_parameter(key, query[key])
675
- opts[key] = { eq: value }
627
+ @where_conditions.keys.select { |k| k.to_s =~ /^#{@key_fields_detector.range_key}\./ }.each do |key|
628
+ name, condition = field_condition(key, @where_conditions[key])
629
+ opts[name] ||= []
630
+ opts[name] << condition
676
631
  end
677
632
  end
678
633
 
679
- opts.merge(query_opts).merge(consistent_opts)
634
+ opts
680
635
  end
681
636
 
682
- def add_range_key_to_range_query(query, opts)
683
- opts[:range_key] = @key_fields_detector.range_key
684
- if query[@key_fields_detector.range_key].present?
685
- value = type_cast_condition_parameter(@key_fields_detector.range_key, query[@key_fields_detector.range_key])
686
- opts.update(range_eq: value)
637
+ def query_non_key_conditions
638
+ opts = {}
639
+
640
+ # Honor STI and :type field if it presents
641
+ if @source.attributes.key?(@source.inheritance_field) &&
642
+ @key_fields_detector.hash_key.to_sym != @source.inheritance_field.to_sym
643
+ @where_conditions.update(sti_condition)
687
644
  end
688
645
 
689
- query.keys.select { |k| k.to_s =~ /^#{@key_fields_detector.range_key}\./ }.each do |key|
690
- opts.merge!(range_hash(key))
646
+ # TODO: Separate key conditions and non-key conditions properly:
647
+ # only =, >, >=, <, <=, between and begins_with
648
+ # could be used for sort key in KeyConditionExpression
649
+ keys = (@where_conditions.keys.map(&:to_sym) - [@key_fields_detector.hash_key.to_sym, @key_fields_detector.range_key.try(:to_sym)])
650
+ .reject { |k, _| k.to_s =~ /^#{@key_fields_detector.range_key}\./ }
651
+ keys.each do |key|
652
+ name, condition = field_condition(key, @where_conditions[key])
653
+ opts[name] ||= []
654
+ opts[name] << condition
691
655
  end
656
+
657
+ opts
692
658
  end
693
659
 
694
660
  # TODO: casting should be operator aware
@@ -736,7 +702,7 @@ module Dynamoid
736
702
  key
737
703
  end
738
704
 
739
- def query_opts
705
+ def query_options
740
706
  opts = {}
741
707
  # Don't specify select = ALL_ATTRIBUTES option explicitly because it's
742
708
  # already a default value of Select statement. Explicite Select value
@@ -748,30 +714,26 @@ module Dynamoid
748
714
  opts[:exclusive_start_key] = start_key if @start
749
715
  opts[:scan_index_forward] = @scan_index_forward
750
716
  opts[:project] = @project
717
+ opts[:consistent_read] = true if @consistent_read
751
718
  opts
752
719
  end
753
720
 
754
- def scan_query
755
- query = self.query
756
-
721
+ def scan_conditions
757
722
  # Honor STI and :type field if it presents
758
723
  if sti_condition
759
- query.update(sti_condition)
724
+ @where_conditions.update(sti_condition)
760
725
  end
761
726
 
762
727
  {}.tap do |opts|
763
- query.keys.map(&:to_sym).each do |key|
764
- if key.to_s.include?('.')
765
- opts.update(field_hash(key))
766
- else
767
- value = type_cast_condition_parameter(key, query[key])
768
- opts[key] = { eq: value }
769
- end
728
+ @where_conditions.keys.map(&:to_sym).each do |key|
729
+ name, condition = field_condition(key, @where_conditions[key])
730
+ opts[name] ||= []
731
+ opts[name] << condition
770
732
  end
771
733
  end
772
734
  end
773
735
 
774
- def scan_opts
736
+ def scan_options
775
737
  opts = {}
776
738
  opts[:index_name] = @key_fields_detector.index_name if @key_fields_detector.index_name
777
739
  opts[:record_limit] = @record_limit if @record_limit
@@ -783,13 +745,14 @@ module Dynamoid
783
745
  opts
784
746
  end
785
747
 
748
+ # TODO: return Array, not String
786
749
  def sti_condition
787
750
  condition = {}
788
751
  type = @source.inheritance_field
789
752
 
790
- if @source.attributes.key?(type)
791
- class_names = @source.deep_subclasses.map(&:name) << @source.name
792
- condition[:"#{type}.in"] = class_names
753
+ if @source.attributes.key?(type) && !@source.abstract_class?
754
+ sti_names = @source.deep_subclasses.map(&:sti_name) << @source.sti_name
755
+ condition[:"#{type}.in"] = sti_names
793
756
  end
794
757
 
795
758
  condition
@@ -5,10 +5,10 @@ module Dynamoid
5
5
  # @private
6
6
  class KeyFieldsDetector
7
7
  class Query
8
- def initialize(query_hash)
9
- @query_hash = query_hash
10
- @fields_with_operator = query_hash.keys.map(&:to_s)
11
- @fields = query_hash.keys.map(&:to_s).map { |s| s.split('.').first }
8
+ def initialize(where_conditions)
9
+ @where_conditions = where_conditions
10
+ @fields_with_operator = where_conditions.keys.map(&:to_s)
11
+ @fields = where_conditions.keys.map(&:to_s).map { |s| s.split('.').first }
12
12
  end
13
13
 
14
14
  def contain_only?(field_names)
@@ -24,10 +24,9 @@ module Dynamoid
24
24
  end
25
25
  end
26
26
 
27
- def initialize(query, source, forced_index_name: nil)
28
- @query = query
27
+ def initialize(where_conditions, source, forced_index_name: nil)
29
28
  @source = source
30
- @query = Query.new(query)
29
+ @query = Query.new(where_conditions)
31
30
  @forced_index_name = forced_index_name
32
31
  @result = find_keys_in_query
33
32
  end
@@ -20,8 +20,8 @@ module Dynamoid
20
20
  fields_list = @nonexistent_fields.map { |s| "`#{s}`" }.join(', ')
21
21
  count = @nonexistent_fields.size
22
22
 
23
- 'where conditions contain nonexistent' \
24
- " field #{'name'.pluralize(count)} #{fields_list}"
23
+ 'where conditions contain nonexistent ' \
24
+ "field #{'name'.pluralize(count)} #{fields_list}"
25
25
  end
26
26
 
27
27
  private
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dynamoid
4
+ module Criteria
5
+ # @private
6
+ class WhereConditions
7
+ def initialize
8
+ @conditions = []
9
+ end
10
+
11
+ def update(hash)
12
+ @conditions << hash.symbolize_keys
13
+ end
14
+
15
+ def keys
16
+ @conditions.flat_map(&:keys)
17
+ end
18
+
19
+ def empty?
20
+ @conditions.empty?
21
+ end
22
+
23
+ def [](key)
24
+ hash = @conditions.find { |h| h.key?(key) }
25
+ hash[key] if hash
26
+ end
27
+ end
28
+ end
29
+ end