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.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/.dockerignore +15 -0
  3. data/.gitignore +9 -5
  4. data/.rspec +1 -0
  5. data/.rubocop.yml +9 -30
  6. data/.travis.yml +18 -2
  7. data/Dockerfile +22 -0
  8. data/Gemfile +0 -1
  9. data/LICENSE.txt +21 -0
  10. data/README.md +75 -17
  11. data/build.sh +10 -20
  12. data/docker-compose.override.example.yml +19 -0
  13. data/docker-compose.yml +7 -2
  14. data/dynamo-record.gemspec +40 -28
  15. data/lib/dynamo/record.rb +17 -0
  16. data/lib/dynamo/record/marshalers.rb +46 -0
  17. data/lib/dynamo/record/model.rb +127 -0
  18. data/lib/dynamo/record/model_existence_validator.rb +10 -0
  19. data/lib/{dynamo-record → dynamo}/record/railtie.rb +1 -3
  20. data/lib/dynamo/record/table_migration.rb +59 -0
  21. data/lib/dynamo/record/task_helpers/cleanup.rb +21 -0
  22. data/lib/dynamo/record/task_helpers/drop_all_tables.rb +19 -0
  23. data/lib/dynamo/record/task_helpers/drop_table.rb +13 -0
  24. data/lib/dynamo/record/task_helpers/list_tables.rb +15 -0
  25. data/lib/dynamo/record/task_helpers/migration_runner.rb +72 -0
  26. data/lib/dynamo/record/task_helpers/scale.rb +90 -0
  27. data/lib/dynamo/record/version.rb +5 -0
  28. data/lib/tasks/dynamo.rake +7 -7
  29. metadata +96 -37
  30. data/Dockerfile.test +0 -23
  31. data/Gemfile.lock +0 -178
  32. data/doc/testing.md +0 -11
  33. data/docker-compose.dev.override.yml +0 -6
  34. data/lib/dynamo-record.rb +0 -4
  35. data/lib/dynamo-record/marshalers.rb +0 -44
  36. data/lib/dynamo-record/model.rb +0 -127
  37. data/lib/dynamo-record/record.rb +0 -7
  38. data/lib/dynamo-record/record/version.rb +0 -5
  39. data/lib/dynamo-record/table_migration.rb +0 -58
  40. data/lib/dynamo-record/task_helpers/cleanup.rb +0 -19
  41. data/lib/dynamo-record/task_helpers/drop_all_tables.rb +0 -17
  42. data/lib/dynamo-record/task_helpers/drop_table.rb +0 -11
  43. data/lib/dynamo-record/task_helpers/list_tables.rb +0 -13
  44. data/lib/dynamo-record/task_helpers/migration_runner.rb +0 -70
  45. data/lib/dynamo-record/task_helpers/scale.rb +0 -86
  46. data/lib/model_existence_validator.rb +0 -7
@@ -1,15 +1,20 @@
1
1
  version: '2'
2
2
 
3
3
  services:
4
- test:
4
+ app:
5
5
  build:
6
6
  context: .
7
- dockerfile: Dockerfile.test
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
 
@@ -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-record/record/version'
3
+ require 'dynamo/record/version'
5
4
 
6
- Gem::Specification.new do |spec|
7
- spec.name = 'dynamo-record'
8
- spec.version = DynamoRecord::Record::VERSION
9
- spec.license = 'MIT'
10
- spec.homepage = 'https://instructure.com'
11
- spec.authors = ['Davis McClellan', 'Ryan Taylor', 'Bryan Petty', 'Michael Brewer-Davis', 'Marc Phillips',
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
- spec.summary = 'Extensions for working with dynamo via aws-record'
18
- spec.description = 'A set of extensions simplifying database operations in aws-record'
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
- spec.files = `git ls-files -z`.split("\x0").reject do |f|
31
+ s.files = `git ls-files -z`.split("\x0").reject do |f|
21
32
  f.match(%r{^(test|spec|features)/})
22
33
  end
23
- spec.bindir = 'exe'
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
- spec.add_dependency 'aws-record', '~> 1.1'
28
- spec.add_dependency 'rails', '~> 4.2'
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
- spec.add_development_dependency 'bundler', '~> 1.14'
31
- spec.add_development_dependency 'byebug', '~> 9.0'
32
- spec.add_development_dependency 'combustion', '~> 0.6.0'
33
- spec.add_development_dependency 'rake', '~> 10.0'
34
- spec.add_development_dependency 'rspec', '~> 3.0'
35
- spec.add_development_dependency 'rubocop', '~> 0.44.1'
36
- spec.add_development_dependency 'simplecov', '~> 0.12'
37
- spec.add_development_dependency 'webmock', '~> 2.1'
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
@@ -1,6 +1,4 @@
1
- require 'dynamo-record/record'
2
- require 'rails'
3
- module DynamoRecord
1
+ module Dynamo
4
2
  module Record
5
3
  class Railtie < Rails::Railtie
6
4
  railtie_name :dynamo_record
@@ -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