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.
- checksums.yaml +4 -4
- data/.gitignore +17 -2
- data/CHANGELOG.md +18 -0
- data/Gemfile +1 -5
- data/LICENSE.txt +22 -0
- data/README.md +6 -190
- data/Rakefile +13 -1
- data/dynomite.gemspec +9 -2
- data/exe/dynomite +14 -0
- data/lib/dynomite/associations/association.rb +126 -0
- data/lib/dynomite/associations/belongs_to.rb +35 -0
- data/lib/dynomite/associations/has_and_belongs_to_many.rb +19 -0
- data/lib/dynomite/associations/has_many.rb +19 -0
- data/lib/dynomite/associations/has_one.rb +19 -0
- data/lib/dynomite/associations/many_association.rb +257 -0
- data/lib/dynomite/associations/single_association.rb +157 -0
- data/lib/dynomite/associations.rb +248 -0
- data/lib/dynomite/autoloader.rb +25 -0
- data/lib/dynomite/cli.rb +48 -0
- data/lib/dynomite/client.rb +118 -0
- data/lib/dynomite/command.rb +89 -0
- data/lib/dynomite/completer/script.rb +6 -0
- data/lib/dynomite/completer/script.sh +10 -0
- data/lib/dynomite/completer.rb +159 -0
- data/lib/dynomite/config.rb +39 -0
- data/lib/dynomite/core.rb +18 -19
- data/lib/dynomite/engine.rb +45 -0
- data/lib/dynomite/erb.rb +5 -3
- data/lib/dynomite/error.rb +12 -0
- data/lib/dynomite/help/completion.md +20 -0
- data/lib/dynomite/help/completion_script.md +3 -0
- data/lib/dynomite/help/migrate.md +3 -0
- data/lib/dynomite/help.rb +9 -0
- data/lib/dynomite/install.rb +4 -0
- data/lib/dynomite/item/abstract.rb +15 -0
- data/lib/dynomite/item/components.rb +33 -0
- data/lib/dynomite/item/dsl.rb +101 -0
- data/lib/dynomite/item/id.rb +41 -0
- data/lib/dynomite/item/indexes/finder.rb +58 -0
- data/lib/dynomite/item/indexes/index.rb +21 -0
- data/lib/dynomite/item/indexes/primary_index.rb +18 -0
- data/lib/dynomite/item/indexes.rb +25 -0
- data/lib/dynomite/item/locking.rb +53 -0
- data/lib/dynomite/item/magic_fields.rb +66 -0
- data/lib/dynomite/item/primary_key.rb +85 -0
- data/lib/dynomite/item/query/delegates.rb +28 -0
- data/lib/dynomite/item/query/params/base.rb +42 -0
- data/lib/dynomite/item/query/params/expression_attribute.rb +79 -0
- data/lib/dynomite/item/query/params/filter.rb +41 -0
- data/lib/dynomite/item/query/params/function/attribute_exists.rb +21 -0
- data/lib/dynomite/item/query/params/function/attribute_type.rb +30 -0
- data/lib/dynomite/item/query/params/function/base.rb +33 -0
- data/lib/dynomite/item/query/params/function/begins_with.rb +32 -0
- data/lib/dynomite/item/query/params/function/contains.rb +7 -0
- data/lib/dynomite/item/query/params/function/size_fn.rb +37 -0
- data/lib/dynomite/item/query/params/helpers.rb +94 -0
- data/lib/dynomite/item/query/params/key_condition.rb +34 -0
- data/lib/dynomite/item/query/params.rb +115 -0
- data/lib/dynomite/item/query/partiql/executer.rb +72 -0
- data/lib/dynomite/item/query/partiql.rb +67 -0
- data/lib/dynomite/item/query/relation/chain.rb +125 -0
- data/lib/dynomite/item/query/relation/comparision_expression.rb +21 -0
- data/lib/dynomite/item/query/relation/comparision_map.rb +19 -0
- data/lib/dynomite/item/query/relation/delete.rb +38 -0
- data/lib/dynomite/item/query/relation/ids.rb +21 -0
- data/lib/dynomite/item/query/relation/math.rb +19 -0
- data/lib/dynomite/item/query/relation/where_field.rb +32 -0
- data/lib/dynomite/item/query/relation/where_group.rb +78 -0
- data/lib/dynomite/item/query/relation.rb +127 -0
- data/lib/dynomite/item/query.rb +7 -0
- data/lib/dynomite/item/read/find.rb +196 -0
- data/lib/dynomite/item/read/find_with_event.rb +42 -0
- data/lib/dynomite/item/read.rb +90 -0
- data/lib/dynomite/item/sti.rb +43 -0
- data/lib/dynomite/item/table_namespace.rb +43 -0
- data/lib/dynomite/item/typecaster.rb +106 -0
- data/lib/dynomite/item/waiter_methods.rb +18 -0
- data/lib/dynomite/item/write/base.rb +15 -0
- data/lib/dynomite/item/write/delete_item.rb +14 -0
- data/lib/dynomite/item/write/put_item.rb +99 -0
- data/lib/dynomite/item/write/update_item.rb +73 -0
- data/lib/dynomite/item/write.rb +204 -0
- data/lib/dynomite/item.rb +113 -286
- data/lib/dynomite/migration/dsl/accessor.rb +19 -0
- data/lib/dynomite/migration/dsl/index/base.rb +42 -0
- data/lib/dynomite/migration/dsl/index/gsi.rb +59 -0
- data/lib/dynomite/migration/dsl/index/lsi.rb +27 -0
- data/lib/dynomite/migration/dsl/index.rb +72 -0
- data/lib/dynomite/migration/dsl/primary_key.rb +62 -0
- data/lib/dynomite/migration/dsl/provisioned_throughput.rb +38 -0
- data/lib/dynomite/migration/dsl.rb +89 -142
- data/lib/dynomite/migration/file_info.rb +28 -0
- data/lib/dynomite/migration/generator.rb +30 -16
- data/lib/dynomite/migration/helpers.rb +7 -0
- data/lib/dynomite/migration/internal/migrate/create_schema_migrations.rb +17 -0
- data/lib/dynomite/migration/internal/models/schema_migration.rb +6 -0
- data/lib/dynomite/migration/runner.rb +178 -0
- data/lib/dynomite/migration/templates/create_table.rb +7 -23
- data/lib/dynomite/migration/templates/delete_table.rb +7 -0
- data/lib/dynomite/migration/templates/update_table.rb +3 -18
- data/lib/dynomite/migration.rb +53 -10
- data/lib/dynomite/reserved_words.rb +13 -3
- data/lib/dynomite/seed.rb +12 -0
- data/lib/dynomite/types.rb +22 -0
- data/lib/dynomite/version.rb +1 -1
- data/lib/dynomite/waiter.rb +40 -0
- data/lib/dynomite.rb +11 -17
- data/lib/generators/application_item/application_item_generator.rb +30 -0
- data/lib/generators/application_item/templates/application_item.rb.tt +4 -0
- data/lib/jets/commands/dynamodb_command.rb +29 -0
- data/lib/jets/commands/help/generate.md +33 -0
- data/lib/jets/commands/help/migrate.md +3 -0
- metadata +201 -17
- data/docs/migrations/long-example.rb +0 -127
- data/docs/migrations/short-example.rb +0 -40
- data/lib/dynomite/db_config.rb +0 -121
- data/lib/dynomite/errors.rb +0 -15
- data/lib/dynomite/log.rb +0 -15
- data/lib/dynomite/migration/common.rb +0 -86
- data/lib/dynomite/migration/dsl/base_secondary_index.rb +0 -73
- data/lib/dynomite/migration/dsl/global_secondary_index.rb +0 -4
- data/lib/dynomite/migration/dsl/local_secondary_index.rb +0 -8
- 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,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
|