dynomite 1.2.7 → 2.0.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 (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