dynamo-record 0.2.0 → 0.3.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 (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