dynamoid 3.8.0 → 3.10.0

Sign up to get free protection for your applications and to get access to all the features.
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