dynamo-record 0.2.0 → 0.3.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.dockerignore +15 -0
- data/.gitignore +9 -5
- data/.rspec +1 -0
- data/.rubocop.yml +9 -30
- data/.travis.yml +18 -2
- data/Dockerfile +22 -0
- data/Gemfile +0 -1
- data/LICENSE.txt +21 -0
- data/README.md +75 -17
- data/build.sh +10 -20
- data/docker-compose.override.example.yml +19 -0
- data/docker-compose.yml +7 -2
- data/dynamo-record.gemspec +40 -28
- data/lib/dynamo/record.rb +17 -0
- data/lib/dynamo/record/marshalers.rb +46 -0
- data/lib/dynamo/record/model.rb +127 -0
- data/lib/dynamo/record/model_existence_validator.rb +10 -0
- data/lib/{dynamo-record → dynamo}/record/railtie.rb +1 -3
- data/lib/dynamo/record/table_migration.rb +59 -0
- data/lib/dynamo/record/task_helpers/cleanup.rb +21 -0
- data/lib/dynamo/record/task_helpers/drop_all_tables.rb +19 -0
- data/lib/dynamo/record/task_helpers/drop_table.rb +13 -0
- data/lib/dynamo/record/task_helpers/list_tables.rb +15 -0
- data/lib/dynamo/record/task_helpers/migration_runner.rb +72 -0
- data/lib/dynamo/record/task_helpers/scale.rb +90 -0
- data/lib/dynamo/record/version.rb +5 -0
- data/lib/tasks/dynamo.rake +7 -7
- metadata +96 -37
- data/Dockerfile.test +0 -23
- data/Gemfile.lock +0 -178
- data/doc/testing.md +0 -11
- data/docker-compose.dev.override.yml +0 -6
- data/lib/dynamo-record.rb +0 -4
- data/lib/dynamo-record/marshalers.rb +0 -44
- data/lib/dynamo-record/model.rb +0 -127
- data/lib/dynamo-record/record.rb +0 -7
- data/lib/dynamo-record/record/version.rb +0 -5
- data/lib/dynamo-record/table_migration.rb +0 -58
- data/lib/dynamo-record/task_helpers/cleanup.rb +0 -19
- data/lib/dynamo-record/task_helpers/drop_all_tables.rb +0 -17
- data/lib/dynamo-record/task_helpers/drop_table.rb +0 -11
- data/lib/dynamo-record/task_helpers/list_tables.rb +0 -13
- data/lib/dynamo-record/task_helpers/migration_runner.rb +0 -70
- data/lib/dynamo-record/task_helpers/scale.rb +0 -86
- data/lib/model_existence_validator.rb +0 -7
data/docker-compose.yml
CHANGED
@@ -1,15 +1,20 @@
|
|
1
1
|
version: '2'
|
2
2
|
|
3
3
|
services:
|
4
|
-
|
4
|
+
app:
|
5
5
|
build:
|
6
6
|
context: .
|
7
|
-
dockerfile: Dockerfile
|
7
|
+
dockerfile: Dockerfile
|
8
8
|
environment:
|
9
9
|
AWS_ACCESS_KEY_ID: x
|
10
10
|
AWS_SECRET_ACCESS_KEY: x
|
11
11
|
AWS_REGION: us-west-2
|
12
12
|
DYNAMO_ENDPOINT: http://dynamo:8000
|
13
|
+
RAILS_ENV: test
|
14
|
+
logging:
|
15
|
+
options:
|
16
|
+
max-file: '1'
|
17
|
+
max-size: 5m
|
13
18
|
links:
|
14
19
|
- dynamo
|
15
20
|
|
data/dynamo-record.gemspec
CHANGED
@@ -1,38 +1,50 @@
|
|
1
|
-
# coding: utf-8
|
2
1
|
lib = File.expand_path('../lib', __FILE__)
|
3
2
|
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
-
require 'dynamo
|
3
|
+
require 'dynamo/record/version'
|
5
4
|
|
6
|
-
Gem::Specification.new do |
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
'Augusto Callejas', 'Frank Murphy']
|
13
|
-
spec.email = ['dmcclellan@instructure.com', 'rtaylor@instructure.com', 'bpetty@instructure.com',
|
14
|
-
'mbd@instructure.com', 'mphillips@instructure.com', 'acallejas@instructure.com',
|
15
|
-
'fmurphy@instructure.com']
|
5
|
+
Gem::Specification.new do |s|
|
6
|
+
s.name = 'dynamo-record'
|
7
|
+
s.version = Dynamo::Record::VERSION
|
8
|
+
s.summary = 'Extensions to Aws::Record for working with DynamoDB.'
|
9
|
+
s.homepage = 'https://github.com/instructure/dynamo-record'
|
10
|
+
s.license = 'MIT'
|
16
11
|
|
17
|
-
|
18
|
-
|
12
|
+
s.authors = [
|
13
|
+
'Davis McClellan',
|
14
|
+
'Ryan Taylor',
|
15
|
+
'Bryan Petty',
|
16
|
+
'Michael Brewer-Davis',
|
17
|
+
'Marc Phillips',
|
18
|
+
'Augusto Callejas',
|
19
|
+
'Frank Murphy'
|
20
|
+
]
|
21
|
+
s.email = [
|
22
|
+
'dmcclellan@instructure.com',
|
23
|
+
'rtaylor@instructure.com',
|
24
|
+
'bpetty@instructure.com',
|
25
|
+
'mbd@instructure.com',
|
26
|
+
'mphillips@instructure.com',
|
27
|
+
'acallejas@instructure.com',
|
28
|
+
'fmurphy@instructure.com'
|
29
|
+
]
|
19
30
|
|
20
|
-
|
31
|
+
s.files = `git ls-files -z`.split("\x0").reject do |f|
|
21
32
|
f.match(%r{^(test|spec|features)/})
|
22
33
|
end
|
23
|
-
|
24
|
-
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
25
|
-
spec.require_paths = ['lib']
|
34
|
+
s.require_paths = ['lib']
|
26
35
|
|
27
|
-
|
28
|
-
|
36
|
+
s.add_dependency 'aws-record', '~> 1.1'
|
37
|
+
s.add_dependency 'activemodel', '>= 4.2', '< 5.2'
|
38
|
+
s.add_dependency 'railties', '>= 4.2', '< 5.2'
|
29
39
|
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
40
|
+
s.add_development_dependency 'activesupport', '>= 4.2', '< 5.2'
|
41
|
+
s.add_development_dependency 'bundler', '~> 1.15'
|
42
|
+
s.add_development_dependency 'byebug', '~> 9.0'
|
43
|
+
s.add_development_dependency 'combustion', '~> 0.6.0'
|
44
|
+
s.add_development_dependency 'rake', '~> 12.0'
|
45
|
+
s.add_development_dependency 'rspec', '~> 3.6'
|
46
|
+
s.add_development_dependency 'rubocop', '~> 0.50.0'
|
47
|
+
s.add_development_dependency 'simplecov', '~> 0.14'
|
48
|
+
s.add_development_dependency 'webmock', '~> 2.1'
|
49
|
+
s.add_development_dependency 'wwtd', '~> 1.3'
|
38
50
|
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
require 'active_model'
|
2
|
+
require 'aws-record'
|
3
|
+
require 'rails/railtie'
|
4
|
+
|
5
|
+
require 'dynamo/record/marshalers'
|
6
|
+
require 'dynamo/record/model'
|
7
|
+
require 'dynamo/record/railtie'
|
8
|
+
require 'dynamo/record/model_existence_validator'
|
9
|
+
require 'dynamo/record/table_migration'
|
10
|
+
require 'dynamo/record/version'
|
11
|
+
|
12
|
+
require 'dynamo/record/task_helpers/cleanup'
|
13
|
+
require 'dynamo/record/task_helpers/drop_all_tables'
|
14
|
+
require 'dynamo/record/task_helpers/drop_table'
|
15
|
+
require 'dynamo/record/task_helpers/list_tables'
|
16
|
+
require 'dynamo/record/task_helpers/migration_runner'
|
17
|
+
require 'dynamo/record/task_helpers/scale'
|
@@ -0,0 +1,46 @@
|
|
1
|
+
module Dynamo
|
2
|
+
module Record
|
3
|
+
module Marshalers
|
4
|
+
COMPOSITE_DELIMETER = '|'.freeze
|
5
|
+
|
6
|
+
def self.included(sub_class)
|
7
|
+
sub_class.extend(ClassMethods)
|
8
|
+
super(sub_class)
|
9
|
+
end
|
10
|
+
|
11
|
+
module ClassMethods
|
12
|
+
def composite_integer_attr(name, opts = {})
|
13
|
+
composite_attr(name, opts)
|
14
|
+
define_readers(name, opts[:parts], :to_i) if opts.key? :parts
|
15
|
+
end
|
16
|
+
|
17
|
+
def composite_string_attr(name, opts = {})
|
18
|
+
composite_attr(name, opts)
|
19
|
+
define_readers(name, opts[:parts], :to_s) if opts.key? :parts
|
20
|
+
end
|
21
|
+
|
22
|
+
private
|
23
|
+
|
24
|
+
def composite_attr(name, opts = {})
|
25
|
+
opts[:dynamodb_type] = 'S'
|
26
|
+
|
27
|
+
# It is very unfortunate that Aws::Record used `attr`
|
28
|
+
# rubocop:disable Style/Attr
|
29
|
+
attr(name, Aws::Record::Marshalers::StringMarshaler.new(opts), opts)
|
30
|
+
# rubocop:enable Style/Attr
|
31
|
+
end
|
32
|
+
|
33
|
+
def define_readers(name, parts, cast_function)
|
34
|
+
parts.each_with_index do |part, i|
|
35
|
+
raise "#{part} already defined" unless parts.find_index(part) == i
|
36
|
+
next if method_defined?(part)
|
37
|
+
define_method(part) do
|
38
|
+
# @data is used internally by Aws::Record to store all of the attributes
|
39
|
+
@data.get_attribute(name).split(COMPOSITE_DELIMETER)[i].send(cast_function)
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
@@ -0,0 +1,127 @@
|
|
1
|
+
module Dynamo
|
2
|
+
module Record
|
3
|
+
module Model
|
4
|
+
COMPOSITE_DELIMITER = '|'.freeze
|
5
|
+
|
6
|
+
def self.included(klass)
|
7
|
+
klass.include(Aws::Record)
|
8
|
+
klass.include(Dynamo::Record::Marshalers)
|
9
|
+
|
10
|
+
klass.extend ClassMethods
|
11
|
+
klass.send :prepend, InstanceMethods
|
12
|
+
end
|
13
|
+
|
14
|
+
module ClassMethods
|
15
|
+
def table_name
|
16
|
+
[Rails.configuration.dynamo['prefix'], name.tableize].join('-').tr('/', '.')
|
17
|
+
end
|
18
|
+
|
19
|
+
def scan
|
20
|
+
raise 'no scanning in production' if Rails.env.production?
|
21
|
+
super
|
22
|
+
end
|
23
|
+
|
24
|
+
def find(opts)
|
25
|
+
super(opts).tap do |record|
|
26
|
+
unless record
|
27
|
+
name = self.name.demodulize
|
28
|
+
conditions = opts.map { |k, v| "#{k}=#{v}" }.join(', ')
|
29
|
+
error = "Couldn't find #{name} with #{conditions}"
|
30
|
+
raise Aws::Record::Errors::NotFound, error
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
def find_all_by_hash_key(hash_key_value, opts = {})
|
36
|
+
find_all_by_index_and_hash_key(hash_key, hash_key_value, opts)
|
37
|
+
end
|
38
|
+
|
39
|
+
def find_all_by_gsi_hash_key(gsi_name, hash_key_value, opts = {})
|
40
|
+
hash_key_name = global_secondary_indexes[gsi_name.to_sym][:hash_key]
|
41
|
+
find_all_by_index_and_hash_key(hash_key_name, hash_key_value, opts, gsi_name.to_s)
|
42
|
+
end
|
43
|
+
|
44
|
+
def find_all_by_gsi_hash_and_range_keys(gsi_name, hash_key_value, range_key_value)
|
45
|
+
gsi_config = global_secondary_indexes[gsi_name.to_sym]
|
46
|
+
find_all_by_index_hash_and_range_keys(
|
47
|
+
hash_config: { name: gsi_config[:hash_key], value: hash_key_value },
|
48
|
+
range_config: { name: gsi_config[:range_key], value: range_key_value },
|
49
|
+
index_name: gsi_name.to_s
|
50
|
+
)
|
51
|
+
end
|
52
|
+
|
53
|
+
def find_all_by_lsi_hash_key(lsi_name, hash_key_value, opts = {})
|
54
|
+
hash_key_name = local_secondary_indexes[lsi_name.to_sym][:hash_key]
|
55
|
+
find_all_by_index_and_hash_key(hash_key_name, hash_key_value, opts, lsi_name.to_s)
|
56
|
+
end
|
57
|
+
|
58
|
+
def find_all_by_lsi_hash_and_range_keys(lsi_name, hash_key_value, range_key_value)
|
59
|
+
lsi_config = local_secondary_indexes[lsi_name.to_sym]
|
60
|
+
find_all_by_index_hash_and_range_keys(
|
61
|
+
hash_config: { name: lsi_config[:hash_key], value: hash_key_value },
|
62
|
+
range_config: { name: lsi_config[:range_key], value: range_key_value },
|
63
|
+
index_name: lsi_name.to_s
|
64
|
+
)
|
65
|
+
end
|
66
|
+
|
67
|
+
def find_all_by_index_and_hash_key(hash_key_name, hash_key_value, opts = {}, index_name = nil)
|
68
|
+
query_options = {
|
69
|
+
select: 'ALL_ATTRIBUTES',
|
70
|
+
key_condition_expression: "#{hash_key_name} = :hash_key_value",
|
71
|
+
expression_attribute_values: {
|
72
|
+
':hash_key_value': hash_key_value
|
73
|
+
},
|
74
|
+
scan_index_forward: true
|
75
|
+
}
|
76
|
+
query_options[:index_name] = index_name if index_name
|
77
|
+
query_options.merge!(opts)
|
78
|
+
query(query_options)
|
79
|
+
end
|
80
|
+
|
81
|
+
def find_all_by_index_hash_and_range_keys(hash_config:, range_config:, index_name: nil,
|
82
|
+
scan_index_forward: true, limit: nil)
|
83
|
+
range_expression = range_config[:expression] || "#{range_config[:name]} = :rkv"
|
84
|
+
query_options = {
|
85
|
+
select: 'ALL_ATTRIBUTES',
|
86
|
+
key_condition_expression: "#{hash_config[:name]} = :hkv AND #{range_expression}",
|
87
|
+
expression_attribute_values: {
|
88
|
+
':hkv': hash_config[:value],
|
89
|
+
':rkv': range_config[:value]
|
90
|
+
},
|
91
|
+
scan_index_forward: scan_index_forward,
|
92
|
+
limit: limit
|
93
|
+
}
|
94
|
+
query_options[:index_name] = index_name if index_name
|
95
|
+
query(query_options)
|
96
|
+
end
|
97
|
+
|
98
|
+
def composite_key(*args)
|
99
|
+
args.join(COMPOSITE_DELIMITER)
|
100
|
+
end
|
101
|
+
|
102
|
+
def split_composite(string)
|
103
|
+
string.split(COMPOSITE_DELIMITER)
|
104
|
+
end
|
105
|
+
|
106
|
+
# TODO: Create a batch save method.
|
107
|
+
end
|
108
|
+
|
109
|
+
module InstanceMethods
|
110
|
+
def read_attribute_for_serialization(attribute)
|
111
|
+
send(attribute)
|
112
|
+
end
|
113
|
+
|
114
|
+
def attribute_hash
|
115
|
+
attrs = self.class.attributes.attributes
|
116
|
+
attr_keys = attrs.keys
|
117
|
+
hash = {}
|
118
|
+
|
119
|
+
attr_keys.each do |key|
|
120
|
+
hash[key] = attrs[key].type_cast(send(key))
|
121
|
+
end
|
122
|
+
hash
|
123
|
+
end
|
124
|
+
end
|
125
|
+
end
|
126
|
+
end
|
127
|
+
end
|
@@ -0,0 +1,10 @@
|
|
1
|
+
module Dynamo
|
2
|
+
module Record
|
3
|
+
class ModelExistenceValidator < ActiveModel::EachValidator
|
4
|
+
def validate_each(record, attribute, value)
|
5
|
+
return if options[:model].exists? value
|
6
|
+
record.errors[attribute] << (options[:message] || "#{attribute}:#{value} is not a valid #{options[:model]}")
|
7
|
+
end
|
8
|
+
end
|
9
|
+
end
|
10
|
+
end
|
@@ -0,0 +1,59 @@
|
|
1
|
+
module Dynamo
|
2
|
+
module Record
|
3
|
+
class TableMigration
|
4
|
+
def self.table_config_check
|
5
|
+
if migrate_table?
|
6
|
+
table_config.migrate!
|
7
|
+
return :migrated
|
8
|
+
end
|
9
|
+
:exists
|
10
|
+
end
|
11
|
+
|
12
|
+
def self.migrate(model)
|
13
|
+
migration = Aws::Record::TableMigration.new(model)
|
14
|
+
begin
|
15
|
+
migration.client.describe_table(table_name: model.table_name)
|
16
|
+
return :exists
|
17
|
+
rescue Aws::DynamoDB::Errors::ResourceNotFoundException
|
18
|
+
yield migration
|
19
|
+
migration.wait_until_available
|
20
|
+
return :migrated
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
def self.migrate_updates(model)
|
25
|
+
migration = Aws::Record::TableMigration.new(model)
|
26
|
+
yield migration
|
27
|
+
migration.wait_until_available
|
28
|
+
:updated
|
29
|
+
end
|
30
|
+
|
31
|
+
def self.add_stream(model)
|
32
|
+
migrate_updates(model) do |migration|
|
33
|
+
migration.update!(
|
34
|
+
stream_specification: {
|
35
|
+
stream_enabled: true,
|
36
|
+
stream_view_type: 'NEW_IMAGE'
|
37
|
+
}
|
38
|
+
)
|
39
|
+
end
|
40
|
+
rescue Aws::DynamoDB::Errors::ValidationException => e
|
41
|
+
return e.message if e.message == 'Table already has an enabled stream'
|
42
|
+
raise e
|
43
|
+
end
|
44
|
+
|
45
|
+
def self.migrate_table?(update_provisioned_throughput = false)
|
46
|
+
unless update_provisioned_throughput
|
47
|
+
table_name = table_config.instance_values['model_class'].table_name
|
48
|
+
described_table = table_config.client.describe_table table_name: table_name
|
49
|
+
provisioned_throughput = described_table.table.provisioned_throughput
|
50
|
+
table_config.read_capacity_units provisioned_throughput.read_capacity_units
|
51
|
+
table_config.write_capacity_units provisioned_throughput.write_capacity_units
|
52
|
+
end
|
53
|
+
!table_config.exact_match?
|
54
|
+
rescue Aws::DynamoDB::Errors::ResourceNotFoundException
|
55
|
+
true
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
module Dynamo
|
2
|
+
module Record
|
3
|
+
module TaskHelpers
|
4
|
+
class Cleanup
|
5
|
+
def self.run
|
6
|
+
raise 'Task not available on production' if Rails.env.production?
|
7
|
+
Dir[Rails.root.join('app', 'models', '*.rb').to_s].each do |filename|
|
8
|
+
delete_by_class(filename)
|
9
|
+
end
|
10
|
+
end
|
11
|
+
|
12
|
+
def self.delete_by_class(filename)
|
13
|
+
klass = File.basename(filename, '.rb').camelize.constantize
|
14
|
+
return unless klass.included_modules.include? Dynamo::Record::Model
|
15
|
+
Rails.logger.info "Deleting all items in table: #{klass}"
|
16
|
+
klass.scan.each(&:delete!)
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
module Dynamo
|
2
|
+
module Record
|
3
|
+
module TaskHelpers
|
4
|
+
class DropAllTables
|
5
|
+
def self.run(override = false)
|
6
|
+
raise 'Task not available on production' if Rails.env.production?
|
7
|
+
env = Rails.env
|
8
|
+
dynamodb = Aws::DynamoDB::Client.new
|
9
|
+
tables = dynamodb.list_tables
|
10
|
+
tables.table_names.map do |t|
|
11
|
+
next unless t.include?(env) || override
|
12
|
+
dt = dynamodb.delete_table(table_name: t)
|
13
|
+
dt ? "Deleted: #{t}" : "Delete failed: #{t}"
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,13 @@
|
|
1
|
+
module Dynamo
|
2
|
+
module Record
|
3
|
+
module TaskHelpers
|
4
|
+
class DropTable
|
5
|
+
def self.run(table_name)
|
6
|
+
dynamodb = Aws::DynamoDB::Client.new
|
7
|
+
resp = dynamodb.delete_table(table_name: table_name)
|
8
|
+
"Deleted: #{resp.table_description.table_name}"
|
9
|
+
end
|
10
|
+
end
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
module Dynamo
|
2
|
+
module Record
|
3
|
+
module TaskHelpers
|
4
|
+
class ListTables
|
5
|
+
def self.run
|
6
|
+
dynamodb = Aws::DynamoDB::Client.new
|
7
|
+
tables = dynamodb.list_tables
|
8
|
+
tables.table_names.select do |tn|
|
9
|
+
tn.starts_with?(Rails.configuration.dynamo['prefix'])
|
10
|
+
end
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|