dynomite 1.2.7 → 2.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (123) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +17 -2
  3. data/CHANGELOG.md +18 -0
  4. data/Gemfile +1 -5
  5. data/LICENSE.txt +22 -0
  6. data/README.md +6 -190
  7. data/Rakefile +13 -1
  8. data/dynomite.gemspec +9 -2
  9. data/exe/dynomite +14 -0
  10. data/lib/dynomite/associations/association.rb +126 -0
  11. data/lib/dynomite/associations/belongs_to.rb +35 -0
  12. data/lib/dynomite/associations/has_and_belongs_to_many.rb +19 -0
  13. data/lib/dynomite/associations/has_many.rb +19 -0
  14. data/lib/dynomite/associations/has_one.rb +19 -0
  15. data/lib/dynomite/associations/many_association.rb +257 -0
  16. data/lib/dynomite/associations/single_association.rb +157 -0
  17. data/lib/dynomite/associations.rb +248 -0
  18. data/lib/dynomite/autoloader.rb +25 -0
  19. data/lib/dynomite/cli.rb +48 -0
  20. data/lib/dynomite/client.rb +118 -0
  21. data/lib/dynomite/command.rb +89 -0
  22. data/lib/dynomite/completer/script.rb +6 -0
  23. data/lib/dynomite/completer/script.sh +10 -0
  24. data/lib/dynomite/completer.rb +159 -0
  25. data/lib/dynomite/config.rb +39 -0
  26. data/lib/dynomite/core.rb +18 -19
  27. data/lib/dynomite/engine.rb +45 -0
  28. data/lib/dynomite/erb.rb +5 -3
  29. data/lib/dynomite/error.rb +12 -0
  30. data/lib/dynomite/help/completion.md +20 -0
  31. data/lib/dynomite/help/completion_script.md +3 -0
  32. data/lib/dynomite/help/migrate.md +3 -0
  33. data/lib/dynomite/help.rb +9 -0
  34. data/lib/dynomite/install.rb +4 -0
  35. data/lib/dynomite/item/abstract.rb +15 -0
  36. data/lib/dynomite/item/components.rb +33 -0
  37. data/lib/dynomite/item/dsl.rb +101 -0
  38. data/lib/dynomite/item/id.rb +41 -0
  39. data/lib/dynomite/item/indexes/finder.rb +58 -0
  40. data/lib/dynomite/item/indexes/index.rb +21 -0
  41. data/lib/dynomite/item/indexes/primary_index.rb +18 -0
  42. data/lib/dynomite/item/indexes.rb +25 -0
  43. data/lib/dynomite/item/locking.rb +53 -0
  44. data/lib/dynomite/item/magic_fields.rb +66 -0
  45. data/lib/dynomite/item/primary_key.rb +85 -0
  46. data/lib/dynomite/item/query/delegates.rb +28 -0
  47. data/lib/dynomite/item/query/params/base.rb +42 -0
  48. data/lib/dynomite/item/query/params/expression_attribute.rb +79 -0
  49. data/lib/dynomite/item/query/params/filter.rb +41 -0
  50. data/lib/dynomite/item/query/params/function/attribute_exists.rb +21 -0
  51. data/lib/dynomite/item/query/params/function/attribute_type.rb +30 -0
  52. data/lib/dynomite/item/query/params/function/base.rb +33 -0
  53. data/lib/dynomite/item/query/params/function/begins_with.rb +32 -0
  54. data/lib/dynomite/item/query/params/function/contains.rb +7 -0
  55. data/lib/dynomite/item/query/params/function/size_fn.rb +37 -0
  56. data/lib/dynomite/item/query/params/helpers.rb +94 -0
  57. data/lib/dynomite/item/query/params/key_condition.rb +34 -0
  58. data/lib/dynomite/item/query/params.rb +115 -0
  59. data/lib/dynomite/item/query/partiql/executer.rb +72 -0
  60. data/lib/dynomite/item/query/partiql.rb +67 -0
  61. data/lib/dynomite/item/query/relation/chain.rb +125 -0
  62. data/lib/dynomite/item/query/relation/comparision_expression.rb +21 -0
  63. data/lib/dynomite/item/query/relation/comparision_map.rb +19 -0
  64. data/lib/dynomite/item/query/relation/delete.rb +38 -0
  65. data/lib/dynomite/item/query/relation/ids.rb +21 -0
  66. data/lib/dynomite/item/query/relation/math.rb +19 -0
  67. data/lib/dynomite/item/query/relation/where_field.rb +32 -0
  68. data/lib/dynomite/item/query/relation/where_group.rb +78 -0
  69. data/lib/dynomite/item/query/relation.rb +127 -0
  70. data/lib/dynomite/item/query.rb +7 -0
  71. data/lib/dynomite/item/read/find.rb +196 -0
  72. data/lib/dynomite/item/read/find_with_event.rb +42 -0
  73. data/lib/dynomite/item/read.rb +90 -0
  74. data/lib/dynomite/item/sti.rb +43 -0
  75. data/lib/dynomite/item/table_namespace.rb +43 -0
  76. data/lib/dynomite/item/typecaster.rb +106 -0
  77. data/lib/dynomite/item/waiter_methods.rb +18 -0
  78. data/lib/dynomite/item/write/base.rb +15 -0
  79. data/lib/dynomite/item/write/delete_item.rb +14 -0
  80. data/lib/dynomite/item/write/put_item.rb +99 -0
  81. data/lib/dynomite/item/write/update_item.rb +73 -0
  82. data/lib/dynomite/item/write.rb +204 -0
  83. data/lib/dynomite/item.rb +113 -286
  84. data/lib/dynomite/migration/dsl/accessor.rb +19 -0
  85. data/lib/dynomite/migration/dsl/index/base.rb +42 -0
  86. data/lib/dynomite/migration/dsl/index/gsi.rb +59 -0
  87. data/lib/dynomite/migration/dsl/index/lsi.rb +27 -0
  88. data/lib/dynomite/migration/dsl/index.rb +72 -0
  89. data/lib/dynomite/migration/dsl/primary_key.rb +62 -0
  90. data/lib/dynomite/migration/dsl/provisioned_throughput.rb +38 -0
  91. data/lib/dynomite/migration/dsl.rb +89 -142
  92. data/lib/dynomite/migration/file_info.rb +28 -0
  93. data/lib/dynomite/migration/generator.rb +30 -16
  94. data/lib/dynomite/migration/helpers.rb +7 -0
  95. data/lib/dynomite/migration/internal/migrate/create_schema_migrations.rb +17 -0
  96. data/lib/dynomite/migration/internal/models/schema_migration.rb +6 -0
  97. data/lib/dynomite/migration/runner.rb +178 -0
  98. data/lib/dynomite/migration/templates/create_table.rb +7 -23
  99. data/lib/dynomite/migration/templates/delete_table.rb +7 -0
  100. data/lib/dynomite/migration/templates/update_table.rb +3 -18
  101. data/lib/dynomite/migration.rb +53 -10
  102. data/lib/dynomite/reserved_words.rb +13 -3
  103. data/lib/dynomite/seed.rb +12 -0
  104. data/lib/dynomite/types.rb +22 -0
  105. data/lib/dynomite/version.rb +1 -1
  106. data/lib/dynomite/waiter.rb +40 -0
  107. data/lib/dynomite.rb +11 -17
  108. data/lib/generators/application_item/application_item_generator.rb +30 -0
  109. data/lib/generators/application_item/templates/application_item.rb.tt +4 -0
  110. data/lib/jets/commands/dynamodb_command.rb +29 -0
  111. data/lib/jets/commands/help/generate.md +33 -0
  112. data/lib/jets/commands/help/migrate.md +3 -0
  113. metadata +201 -17
  114. data/docs/migrations/long-example.rb +0 -127
  115. data/docs/migrations/short-example.rb +0 -40
  116. data/lib/dynomite/db_config.rb +0 -121
  117. data/lib/dynomite/errors.rb +0 -15
  118. data/lib/dynomite/log.rb +0 -15
  119. data/lib/dynomite/migration/common.rb +0 -86
  120. data/lib/dynomite/migration/dsl/base_secondary_index.rb +0 -73
  121. data/lib/dynomite/migration/dsl/global_secondary_index.rb +0 -4
  122. data/lib/dynomite/migration/dsl/local_secondary_index.rb +0 -8
  123. data/lib/dynomite/migration/executor.rb +0 -38
@@ -0,0 +1,21 @@
1
+ class Dynomite::Item::Query::Relation
2
+ class ComparisionExpression
3
+ def initialize(where_group, comparisions)
4
+ @where_group, @comparisions = where_group, comparisions
5
+ end
6
+
7
+ def or?
8
+ @where_group.or?
9
+ end
10
+
11
+ def build
12
+ # join @comparisions with AND if there are more than one
13
+ expression = []
14
+ expression << 'NOT' if @where_group.not?
15
+ expression << '(' if @comparisions.size > 1
16
+ expression << @comparisions.join(' AND ') # always AND within a group
17
+ expression << ')' if @comparisions.size > 1
18
+ expression.join(' ')
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,19 @@
1
+ class Dynomite::Item::Query::Relation
2
+ module ComparisionMap
3
+ COMPARISION_MAP = {
4
+ 'eq' => '=',
5
+ 'gt' => '>',
6
+ 'gte' => '>=',
7
+ 'lt' => '<',
8
+ 'lte' => '<=',
9
+ }
10
+
11
+ def comparision_for(operator)
12
+ COMPARISION_MAP[operator] || operator
13
+ end
14
+
15
+ def comparision_operators
16
+ COMPARISION_MAP.keys + COMPARISION_MAP.values
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,38 @@
1
+ class Dynomite::Item::Query::Relation
2
+ module Delete
3
+ def delete_all
4
+ # https://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/DynamoDB/Client.html#batch_write_item-instance_method
5
+ # A single call to BatchWriteItem can transmit up to 16MB of data over the network,
6
+ # consisting of up to 25 item put or delete operations.
7
+ batch_limit = 25 # max batch size for batch_write_item
8
+ each_page.each do |page|
9
+ page.each_slice(batch_limit) do |slice|
10
+ primary_keys = slice.map(&:primary_key)
11
+ delete_requests = primary_keys.map do |primary_key|
12
+ {
13
+ delete_request: {
14
+ key: primary_key,
15
+ },
16
+ }
17
+ end
18
+ request_items = { @source.table_name => delete_requests }
19
+ client.batch_write_item(request_items: request_items)
20
+ end
21
+ end
22
+ end
23
+
24
+ def destroy_all
25
+ each(&:destroy)
26
+ end
27
+
28
+ # require args
29
+ def delete_by(args)
30
+ where(args).delete_all
31
+ end
32
+
33
+ # require args
34
+ def destroy_by(args)
35
+ where(args).destroy_all
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,21 @@
1
+ class Dynomite::Item::Query::Relation
2
+ module Ids
3
+ def pluck(*names)
4
+ project(*names)
5
+ super # provided by Ruby Enumerable
6
+ end
7
+
8
+ def ids
9
+ project(:id).each.map(&:id).to_a
10
+ end
11
+
12
+ def exists?(args={})
13
+ !!limit(1).first
14
+ end
15
+
16
+ # Surprisingly, Enumberable does not provide empty?
17
+ def empty?
18
+ !exists?
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,19 @@
1
+ class Dynomite::Item::Query::Relation
2
+ module Math
3
+ def average(field)
4
+ map(&field).sum.to_f / count
5
+ end
6
+
7
+ def min(field)
8
+ map(&field).min
9
+ end
10
+
11
+ def max(field)
12
+ map(&field).max
13
+ end
14
+
15
+ def sum(field)
16
+ map(&field).sum
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,32 @@
1
+ class Dynomite::Item::Query::Relation
2
+ class WhereField
3
+ attr_reader :full_field, :value, :index
4
+ def initialize(full_field, value, index)
5
+ @full_field, @value, @index = full_field.to_s, value, index
6
+ end
7
+
8
+ def field
9
+ @full_field.split('.').first
10
+ end
11
+
12
+ def operator
13
+ if raw_operator
14
+ not? ? raw_operator[4..-1] : raw_operator
15
+ end
16
+ end
17
+
18
+ def not?
19
+ raw_operator.match(/^not_/) if raw_operator
20
+ end
21
+
22
+ def raw_operator
23
+ _, operator = @full_field.split('.')
24
+ operator.downcase if operator
25
+ end
26
+
27
+ # Example: price_1, price_2
28
+ def reference
29
+ "#{field}_#{@index}"
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,78 @@
1
+ class Dynomite::Item::Query::Relation
2
+ class WhereGroup
3
+ delegate :keys, :size, to: :hash
4
+ include ComparisionMap
5
+
6
+ attr_accessor :hash, :meta
7
+ def initialize(relation, hash={}, meta={})
8
+ @relation = relation
9
+ @hash = hash
10
+ @meta = meta
11
+ end
12
+
13
+ def to_s
14
+ "#<#{self.class.name} @hash=#{@hash} @meta=#{@meta}>"
15
+ end
16
+
17
+ def each
18
+ @hash.each do |full_field, value|
19
+ where_field = WhereField.new(full_field, value, @relation.index)
20
+ yield where_field
21
+ @relation.index += 1
22
+ end
23
+ end
24
+
25
+ def fields
26
+ @hash.map do |full_field, value|
27
+ full_field.to_s.split('.').first
28
+ end
29
+ end
30
+
31
+ def not?
32
+ @meta[:not]
33
+ end
34
+
35
+ def or?
36
+ @meta[:or]
37
+ end
38
+
39
+ # Method helps remove duplication and DRY up building of compare expression.
40
+ # It a bit confusing but unsure how to make it clearer.
41
+ def build_compare_expression_if
42
+ comparisions = []
43
+ each do |where_field|
44
+ field = where_field.field
45
+ next unless yield(field) # only build if condition is true
46
+ comparisions << build_compare(where_field)
47
+ end
48
+ unless comparisions.empty?
49
+ ComparisionExpression.new(self, comparisions) # to pass in where_group for or? and not?
50
+ end
51
+ end
52
+
53
+ def build_compare(where_field)
54
+ reference = where_field.reference
55
+ operator = where_field.operator
56
+ expression = case operator
57
+ when 'in'
58
+ # ProductStatus in (:avail, :back, :disc)
59
+ array = Array(where_field.value)
60
+ list = array.map.with_index { |v, i| ":#{reference}_#{i}" }.join(', ') # values
61
+ "##{reference} in (#{list})"
62
+ when 'between'
63
+ # sortKeyName between :sortkeyval1 AND :sortkeyval2
64
+ "##{reference} between :#{reference}_0 AND :#{reference}_1"
65
+ when 'begins_with'
66
+ # begins_with ( sortKeyName, :sortkeyval )
67
+ "begins_with(##{reference}, :#{reference})"
68
+ when *comparision_operators # eq, gt, gte, lt, lte, =, >, >=, <, <=
69
+ comparision = comparision_for(operator)
70
+ "##{reference} #{comparision} :#{reference}"
71
+ else
72
+ "##{reference} = :#{reference}"
73
+ end
74
+ expression = "NOT (#{expression})" if where_field.not?
75
+ expression
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,127 @@
1
+ module Dynomite::Item::Query
2
+ # Builds up the query with methods like where and eventually executes Query or Scan.
3
+ class Relation
4
+ extend Memoist
5
+ include Dynomite::Client
6
+ include Enumerable
7
+ include Chain
8
+ include Math
9
+ include Ids
10
+ include Delete
11
+
12
+ attr_accessor :index, :query, :source
13
+ def initialize(source)
14
+ @source = source # source is the model class. IE: Post User etc
15
+ @query = {
16
+ where: [],
17
+ attribute_exists: [],
18
+ attribute_not_exists: [],
19
+ attribute_type: [],
20
+ begins_with: [],
21
+ contains: [],
22
+ size_fn: [],
23
+ }
24
+ @index = 0
25
+ end
26
+
27
+ # Enumerable provides .to_a, add force so it's more like lazy Enumerable.
28
+ # Also, not using: `alias load to_a` because load is Enumerable private method
29
+ # Instead user should use force or eager. Docs:
30
+ # https://docs.ruby-lang.org/en/master/Enumerator/Lazy.html
31
+ # https://www.rubydoc.info/stdlib/core/Enumerator/Lazy
32
+ alias size count
33
+ alias force to_a
34
+
35
+ def each(&block)
36
+ items.each(&block)
37
+ end
38
+
39
+ def items
40
+ pages.flat_map { |i| i } # flat_map flattens the Lazy Enumerator since yielding pages
41
+ end
42
+ private :items
43
+
44
+ def pages
45
+ raw_pages.map do |raw_page|
46
+ raw_page.items.map.map(&method(:build_item))
47
+ end
48
+ end
49
+ alias each_page pages
50
+
51
+ def raw_pages
52
+ params = to_params
53
+ # total_limit is the total limit across all pages
54
+ # For the AWS API call itself use the default limit and allow AWS to scan 1MB for page
55
+ total_limit = params.delete(:limit)
56
+ total_count = 0
57
+ Enumerator.new do |y|
58
+ last_evaluated_key = :start
59
+ while last_evaluated_key
60
+ if last_evaluated_key && last_evaluated_key != :start
61
+ params[:exclusive_start_key] = last_evaluated_key
62
+ end
63
+
64
+ meth = params[:key_condition_expression] ? :query : :scan
65
+ log_debug(params)
66
+ raw_warn_scan if meth == :scan
67
+ response = client.send(meth, params) # scan or query
68
+ records = response.items.map { |i| build_item(i, run_callback: false) }
69
+ y.yield(response, records)
70
+
71
+ # Track total_count across pages. If limit is set, then stop when we reach it.
72
+ # Since limit can be greater than each API response paged size.
73
+ total_count += response.items.size
74
+ break if total_limit && total_count >= total_limit
75
+
76
+ last_evaluated_key = response.last_evaluated_key
77
+ end
78
+ end.lazy
79
+ end
80
+ alias each_raw_page raw_pages
81
+
82
+ def build_item(i, run_callback: true)
83
+ item = @source.new(i) # IE: Post.new(i)
84
+ item.new_record = false
85
+ item.run_callbacks :find if run_callback
86
+ item
87
+ end
88
+
89
+ # Enumerable provides .first but does not provide .last
90
+ # Note, cannot use:
91
+ # scan_index_forward(false).limit(1).first
92
+ # Since that will not work for queries that do not have a sort key.
93
+ # Users need to use query directly if they want to find the last item more efficiently.
94
+ def last
95
+ warn_scan <<~EOL
96
+ WARN: Dynomite::Item::Query::Relation#last is slow.
97
+ Consider using query directly if you have a primary key that as a sort key.
98
+ EOL
99
+ to_a.last # force load of lazy enumerator. slow
100
+ end
101
+
102
+ def to_params
103
+ Params.new(self, @source).to_h
104
+ end
105
+
106
+ # Allows all to chain itself. This allows.
107
+ #
108
+ # Post.where(category: "Electronics").all
109
+ # Post.limit(1).all
110
+ # Post.all.all # also works, side effect
111
+ #
112
+ def all
113
+ self
114
+ end
115
+
116
+ def raw_warn_scan
117
+ warn_on_scan = @warn_on_scan.nil? ? Dynomite.config.warn_on_scan : @warn_on_scan
118
+ return unless warn_on_scan
119
+ warn_scan <<~EOL
120
+ WARN: Scanning detected. It's recommended to not use scan. It can be slow.
121
+ Scanning table: #{@source.table_name}
122
+ Try creating a LSI or GSI index so dynomite can use query instead.
123
+ Docs: https://rubyonjets.com/docs/database/dynamodb/indexing/
124
+ EOL
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,7 @@
1
+ class Dynomite::Item
2
+ module Query
3
+ extend ActiveSupport::Concern
4
+ include Partiql
5
+ include Delegates
6
+ end
7
+ end
@@ -0,0 +1,196 @@
1
+ module Dynomite::Item::Read
2
+ module Find
3
+ extend ActiveSupport::Concern
4
+
5
+ # Note: options are merged into get_item params.
6
+ def find(id, options={})
7
+ self.class.find(id, options)
8
+ end
9
+
10
+ class_methods do
11
+ def find_by(attrs, options={})
12
+ # Note: ActionController::Parameters does not have .any? Unsure if should use .to_h on it
13
+ # A blank attrs will break find_by since DynamoDB doesnt support blank attribute values
14
+ # Guard blank attrs like and return new return so validation error surfaces in a standard CRUD scaffold
15
+ attrs = attrs.to_h
16
+ return nil if attrs.any? { |k,v| v.blank? }
17
+
18
+ primary_key_attrs = attrs.stringify_keys.slice(partition_key_field, sort_key_field).symbolize_keys
19
+ if primary_key_attrs.size == primary_key_fields.size
20
+ find(primary_key_attrs, options.merge(raise_error: false))
21
+ else
22
+ where(attrs).first # possible scan
23
+ end
24
+ end
25
+
26
+ # Examples with out the args are received.
27
+ #
28
+ # Post.find("ae3ae")
29
+ # args ["ae3ae"]
30
+ # Post.find(id: "ae3ae")
31
+ # args [{:id=>"ae3ae"}]
32
+ # Post.find("ae3ae", consistent_read: true)
33
+ # args ["ae3ae", {:consistent_read=>true}]
34
+ # Post.find({id: "ae3ae"}, consistent_read: true)
35
+ # args [{:id=>"ae3ae"}, {:consistent_read=>true}]
36
+ # Product.find(category: "Electronics", product_id: 101)
37
+ # args [{:category=>"Electronics", :product_id=>101}]
38
+ # Product.find({category: "Electronics", product_id: 101}, consistent_read: true)
39
+ # args [{:category=>"Electronics", :product_id=>101}, {:consistent_read=>true}]
40
+ #
41
+ # Product.find(id1,id2,id3)
42
+ # args [id1,id2,id3]
43
+ # Product.find([id1,id2,id3])
44
+ # args [[id1,id2,id3]]
45
+ # Product.find([id1,id2,id3], consistent_read: true)
46
+ # args [[id1,id2,id3], {:consistent_read=>true}]
47
+ #
48
+ # Note: options are merged into get_item params.
49
+ def find(*args)
50
+ options = {}
51
+ key_schema = if args.size == 1
52
+ # args is an array of one element:
53
+ # ["f74de472"]
54
+ # [{:id=>"f74de472"}]
55
+ # [{:category=>"Electronics", :product_id=>101}]
56
+ # [[id1, id2, id3]]
57
+ get_key_schema_from_one_arg(args.first)
58
+ else
59
+ # ["f74de472", {:consistent_read=>true}]
60
+ # [{:id=>"f74de472"}, {:consistent_read=>true}]
61
+ # [{:category=>"Electronics", :product_id=>101}, {:consistent_read=>true}]
62
+ # [[id1, id2, id3], {:consistent_read=>true}] # HERE
63
+ options = args.extract_options!
64
+ if args.size >= 2 # still at least 2 after extracting options
65
+ get_key_schema_from_one_arg(args) # [id1, id2, id3]
66
+ else
67
+ get_key_schema_from_one_arg(args.first)
68
+ end
69
+ end
70
+
71
+ raise_error = options.delete(:raise_error) # clean for params
72
+ raise_error = raise_error.nil? ? true : raise_error
73
+
74
+ # Early return if ids the provided arg. find(ids)
75
+ # List of ids: IE: [id1, id2, id3]
76
+ if key_schema.is_a?(Array)
77
+ keys = key_schema # [{"id"=>"post-1"}, {"id"=>"post-2"}]
78
+ ids = keys.map(&:values).flatten # ["post-1", "post-2"]
79
+ items = batch_get_items(keys, options)
80
+ result = items
81
+ result = nil if items.size != keys.size # some missing items
82
+
83
+ if result.nil?
84
+ if raise_error
85
+ looking, found = ids, items.map(&:id)
86
+ missing = ids - found
87
+ message = "Couldn't find all #{self.name.pluralize} with '#{partition_key_field}': (#{looking.join(', ')}) (found #{found.size} results, but was looking for #{looking.size})"
88
+ message << ". Missing: #{missing.sort.join(', ')}" if found.size > 0
89
+ raise Dynomite::Error::RecordNotFound.new(message)
90
+ else
91
+ return items # return early
92
+ end
93
+ else
94
+ return items
95
+ end
96
+ end
97
+
98
+ params = {
99
+ table_name: table_name,
100
+ key: key_schema,
101
+ }
102
+ params.merge!(options)
103
+
104
+ log_debug(params)
105
+ attrs = client.get_item(params).item # unwraps the item's attrs
106
+
107
+ # Mimic ActiveRecord::RecordNotFound behavior
108
+ raise Dynomite::Error::RecordNotFound if attrs.nil? && raise_error != false
109
+
110
+ build_item(attrs)
111
+ end
112
+
113
+ def batch_get_items(*args)
114
+ options = args.extract_options!
115
+ retries = options.delete(:retries)|| 0
116
+ keys = args.flatten
117
+ items = []
118
+ unprocessed_keys = []
119
+
120
+ # exponential backoff to handle unprocessed keys
121
+ delay = 2 ** retries
122
+ if retries > 0
123
+ logger.debug "batch_get_items: sleeping for #{delay} seconds and will retry. retries: #{retries}"
124
+ sleep(delay)
125
+ if retries >= 3 # 2 + 4 + 8 = 14s total of retries
126
+ raise "ERROR: Exceeded max retries: #{retries}. Unable to batch_get_items for keys: #{keys.inspect}"
127
+ end
128
+ end
129
+
130
+ max_batch_size = 100
131
+ keys.each_slice(max_batch_size).each do |slice|
132
+ # Note: client.batch_get_items will silently not return items if any of the
133
+ # keys are not found. So we do not have to do a compact and remove nils.
134
+ # Merge options to allow passing in consistent_read: true
135
+ params = options.merge(keys: slice)
136
+ resp = client.batch_get_item(
137
+ request_items: {
138
+ table_name => params
139
+ }
140
+ )
141
+ resp[:responses][table_name].each do |item|
142
+ items << build_item(item)
143
+ end
144
+ unprocessed_keys += resp[:unprocessed_keys][table_name] if resp[:unprocessed_keys][table_name]
145
+ end
146
+
147
+ # Recursively call batch_get_items if there are unprocessed_keys
148
+ # Increase the retries by 1 for exponential backoff
149
+ items += batch_get_items(unprocessed_keys, options.merge(retries: retries+1)) if unprocessed_keys.any?
150
+
151
+ items
152
+ end
153
+
154
+ def build_item(attrs)
155
+ return unless attrs # is nil when no item found: client.get_item(params).item
156
+ item = self.new(attrs)
157
+ item.new_record = false
158
+ item.run_callbacks :find # find and find_by leads to build_item. so we can run callbacks here
159
+ item
160
+ end
161
+
162
+ # Examples of id:
163
+ #
164
+ # "f74de472"
165
+ # {:id=>"f74de472"}
166
+ # {:category=>"Electronics", :product_id=>101}
167
+ # [id1, id2, id3]
168
+ #
169
+ # Returns hash of key_schema
170
+ #
171
+ # { id: "f74de472" }
172
+ # { category: "Electronics", product_id: 101 }
173
+ #
174
+ def get_key_schema_from_one_arg(id)
175
+ # standardize key structure
176
+ case id
177
+ when Integer, String, Symbol # "f74de472"
178
+ if id.is_a?(String) && id.starts_with?(id_prefix)
179
+ { id: id }
180
+ else
181
+ if sort_key_field
182
+ raise "ERROR: You must provide both partition and sort key for class: #{self.name} partition_key_field: #{partition_key_field} sort_key_field: #{sort_key_field}"
183
+ end
184
+ { partition_key_field => id }
185
+ end
186
+ when Hash # {:id=>"f74de472"} or {:category=>"Electronics", :product_id=>101}
187
+ id # User needs to provide both partition and sort key
188
+ when Array # [id1, id2, id3]
189
+ id.map do |i|
190
+ i.is_a?(Hash) ? i : { partition_key_field => i }
191
+ end
192
+ end
193
+ end
194
+ end
195
+ end
196
+ end
@@ -0,0 +1,42 @@
1
+ module Dynomite::Item::Read
2
+ module FindWithEvent
3
+ extend ActiveSupport::Concern
4
+
5
+ class_methods do
6
+ def find_all_with_stream_event(event, options={})
7
+ # For event payload structure see
8
+ # https://rubyonjets.com/docs/events/dynamodb/#event-payload
9
+ # Keys structure:
10
+ # {
11
+ # "Records": [
12
+ # "dynamodb": {
13
+ # "Keys": {
14
+ # "id": {
15
+ # "S": "post-1"
16
+ # }
17
+ # },
18
+ event = JSON.load(event) if event.is_a?(String)
19
+ event = event.deep_symbolize_keys
20
+ # raw_keys: { "id": { "S": "post-1" } }
21
+ raw_keys = event[:Records].map do |record|
22
+ record[:dynamodb][:Keys]
23
+ end
24
+ # keys: { id: "post-1" }
25
+ keys = get_key_schema_from_raw_keys(raw_keys)
26
+ items = find(keys, options) # find can return single item or Array of items
27
+ Array(items) # ensure Array is returned
28
+ end
29
+
30
+ def get_key_schema_from_raw_keys(raw_keys)
31
+ # raw_keys: { "id": { "S": "post-1" } }
32
+ # keys: { id: "post-1" }
33
+ # Note: raw_keys can have duplicates.
34
+ # IE: [{ "id": { "S": "post-1" }, "id": { "S": "post-1" } }]
35
+ keys = raw_keys.uniq.map do |hash|
36
+ hash.transform_values { |value| value.values.first }
37
+ end
38
+ get_key_schema_from_one_arg(keys)
39
+ end
40
+ end
41
+ end
42
+ end