active_record_streams 0.1.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 (40) hide show
  1. checksums.yaml +7 -0
  2. data/.github/PULL_REQUEST_TEMPLATE.md +35 -0
  3. data/.gitignore +12 -0
  4. data/.rspec +5 -0
  5. data/.rubocop.yml +10 -0
  6. data/.travis.yml +12 -0
  7. data/CHANGELOG.md +13 -0
  8. data/Gemfile +7 -0
  9. data/Gemfile.lock +96 -0
  10. data/MIT-LICENSE +20 -0
  11. data/README.md +168 -0
  12. data/Rakefile +8 -0
  13. data/active_record_streams.gemspec +55 -0
  14. data/lib/active_record_streams.rb +26 -0
  15. data/lib/active_record_streams/config.rb +15 -0
  16. data/lib/active_record_streams/config_spec.rb +32 -0
  17. data/lib/active_record_streams/credentials.rb +31 -0
  18. data/lib/active_record_streams/credentials_spec.rb +79 -0
  19. data/lib/active_record_streams/extensions/active_record/base.rb +41 -0
  20. data/lib/active_record_streams/extensions/active_record/base_spec.rb +45 -0
  21. data/lib/active_record_streams/extensions/active_record/persistence.rb +17 -0
  22. data/lib/active_record_streams/extensions/active_record/persistence_spec.rb +36 -0
  23. data/lib/active_record_streams/extensions/active_record/relation.rb +60 -0
  24. data/lib/active_record_streams/extensions/active_record/relation_spec.rb +41 -0
  25. data/lib/active_record_streams/message.rb +21 -0
  26. data/lib/active_record_streams/message_spec.rb +29 -0
  27. data/lib/active_record_streams/publishers/http_stream.rb +58 -0
  28. data/lib/active_record_streams/publishers/http_stream_spec.rb +73 -0
  29. data/lib/active_record_streams/publishers/kinesis_client.rb +26 -0
  30. data/lib/active_record_streams/publishers/kinesis_client_spec.rb +49 -0
  31. data/lib/active_record_streams/publishers/kinesis_stream.rb +64 -0
  32. data/lib/active_record_streams/publishers/kinesis_stream_spec.rb +93 -0
  33. data/lib/active_record_streams/publishers/sns_client.rb +25 -0
  34. data/lib/active_record_streams/publishers/sns_client_spec.rb +46 -0
  35. data/lib/active_record_streams/publishers/sns_stream.rb +52 -0
  36. data/lib/active_record_streams/publishers/sns_stream_spec.rb +89 -0
  37. data/lib/active_record_streams/version.rb +5 -0
  38. data/lib/active_record_streams/version_spec.rb +9 -0
  39. data/lib/active_record_streams_spec.rb +21 -0
  40. metadata +199 -0
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_record_streams/version'
4
+ require 'active_record_streams/config'
5
+ require 'active_record_streams/message'
6
+ require 'active_record_streams/credentials'
7
+
8
+ require 'active_record_streams/publishers/kinesis_client'
9
+ require 'active_record_streams/publishers/kinesis_stream'
10
+ require 'active_record_streams/publishers/sns_client'
11
+ require 'active_record_streams/publishers/sns_stream'
12
+ require 'active_record_streams/publishers/http_stream'
13
+
14
+ require 'active_record_streams/extensions/active_record/persistence'
15
+ require 'active_record_streams/extensions/active_record/relation'
16
+ require 'active_record_streams/extensions/active_record/base'
17
+
18
+ module ActiveRecordStreams
19
+ def self.configure
20
+ yield config
21
+ end
22
+
23
+ def self.config
24
+ @config ||= Config.new
25
+ end
26
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecordStreams
4
+ class Config
5
+ attr_accessor :aws_region, :aws_access_key_id, :aws_secret_access_key,
6
+ :streams
7
+
8
+ def initialize
9
+ @aws_region = nil
10
+ @aws_access_key_id = nil
11
+ @aws_secret_access_key = nil
12
+ @streams = []
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'config'
4
+
5
+ RSpec.describe ActiveRecordStreams::Config do
6
+ describe 'attr accessors' do
7
+ context 'initial values' do
8
+ it 'returns correct initial values' do
9
+ expect(subject.aws_region).to eq(nil)
10
+ expect(subject.aws_access_key_id).to eq(nil)
11
+ expect(subject.aws_secret_access_key).to eq(nil)
12
+ expect(subject.streams).to eq([])
13
+ end
14
+ end
15
+
16
+ context 'attr setters' do
17
+ it 'sets and returns proper values' do
18
+ subject.aws_region = 'test-region'
19
+ expect(subject.aws_region).to eq('test-region')
20
+
21
+ subject.aws_access_key_id = 'test-access-key-id'
22
+ expect(subject.aws_access_key_id).to eq('test-access-key-id')
23
+
24
+ subject.aws_secret_access_key = 'test-secret-access-key'
25
+ expect(subject.aws_secret_access_key).to eq('test-secret-access-key')
26
+
27
+ subject.streams << 'test-stream'
28
+ expect(subject.streams).to eq(['test-stream'])
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecordStreams
4
+ class Credentials
5
+ def build_credentials
6
+ region_config
7
+ .merge(access_key_id_config)
8
+ .merge(secret_access_key_config)
9
+ end
10
+
11
+ private
12
+
13
+ def region_config
14
+ return {} unless ActiveRecordStreams.config.aws_region
15
+
16
+ { region: ActiveRecordStreams.config.aws_region }
17
+ end
18
+
19
+ def access_key_id_config
20
+ return {} unless ActiveRecordStreams.config.aws_access_key_id
21
+
22
+ { access_key_id: ActiveRecordStreams.config.aws_access_key_id }
23
+ end
24
+
25
+ def secret_access_key_config
26
+ return {} unless ActiveRecordStreams.config.aws_secret_access_key
27
+
28
+ { secret_access_key: ActiveRecordStreams.config.aws_secret_access_key }
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'credentials'
4
+
5
+ RSpec.describe ActiveRecordStreams::Credentials do
6
+ describe '#build_credentials' do
7
+ let(:config) do
8
+ double(
9
+ aws_region: nil,
10
+ aws_access_key_id: nil,
11
+ aws_secret_access_key: nil
12
+ )
13
+ end
14
+
15
+ before do
16
+ allow(ActiveRecordStreams).to receive(:config).and_return(config)
17
+ end
18
+
19
+ subject { described_class.new.build_credentials }
20
+
21
+ context 'has region config' do
22
+ before do
23
+ allow(config).to receive(:aws_region).and_return('eu-central-1')
24
+ end
25
+
26
+ it 'returns a proper config' do
27
+ expect(subject).to eq(region: 'eu-central-1')
28
+ end
29
+ end
30
+
31
+ context 'has no region config' do
32
+ it 'returns a proper config' do
33
+ expect(subject).to eq({})
34
+ end
35
+ end
36
+
37
+ context 'has access keys config' do
38
+ before do
39
+ allow(config).to receive(:aws_access_key_id).and_return('access-key-id')
40
+ allow(config).to receive(:aws_secret_access_key).and_return('secret')
41
+ end
42
+
43
+ it 'returns a proper config' do
44
+ expect(subject).to eq(
45
+ access_key_id: 'access-key-id',
46
+ secret_access_key: 'secret'
47
+ )
48
+ end
49
+ end
50
+
51
+ context 'has no access keys config' do
52
+ it 'returns a proper config' do
53
+ expect(subject).to eq({})
54
+ end
55
+ end
56
+
57
+ context 'has region and access key config' do
58
+ before do
59
+ allow(config).to receive(:aws_region).and_return('eu-central-1')
60
+ allow(config).to receive(:aws_access_key_id).and_return('access-key-id')
61
+ allow(config).to receive(:aws_secret_access_key).and_return('secret')
62
+ end
63
+
64
+ it 'returns a proper config' do
65
+ expect(subject).to eq(
66
+ region: 'eu-central-1',
67
+ access_key_id: 'access-key-id',
68
+ secret_access_key: 'secret'
69
+ )
70
+ end
71
+ end
72
+
73
+ context 'has no any config' do
74
+ it 'returns a proper config' do
75
+ expect(subject).to eq({})
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecord
4
+ Base.prepend(
5
+ Module.new do
6
+ def self.prepended(object)
7
+ object.class_exec do
8
+ before_save :capture_old_image
9
+ after_commit :publish_event_to_streams
10
+ end
11
+ end
12
+
13
+ private
14
+
15
+ def capture_old_image
16
+ @old_image = attributes.keys.map do |attribute|
17
+ [attribute, attribute_was(attribute)]
18
+ end.to_h
19
+ end
20
+
21
+ def publish_event_to_streams
22
+ ActiveRecordStreams.config.streams.each do |stream|
23
+ stream.publish(self.class.table_name, stream_message)
24
+ end
25
+ end
26
+
27
+ def stream_message
28
+ ::ActiveRecordStreams::Message.new(
29
+ self.class.table_name, stream_action_type, @old_image, attributes
30
+ )
31
+ end
32
+
33
+ def stream_action_type
34
+ return :create if transaction_include_any_action?([:create])
35
+ return :destroy if transaction_include_any_action?([:destroy])
36
+
37
+ :update
38
+ end
39
+ end
40
+ )
41
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../../../spec/mocks/active_record/base'
4
+ require_relative 'base'
5
+
6
+ RSpec.describe ActiveRecord::Base do
7
+ let(:message) { double(new: nil) }
8
+ let(:stream) { double(publish: nil) }
9
+ let(:streams_config) { double(streams: [stream]) }
10
+
11
+ before do
12
+ allow(ActiveRecordStreams).to receive(:config).and_return(streams_config)
13
+ end
14
+
15
+ describe 'persistence' do
16
+ let(:old_image) do
17
+ {
18
+ 'hello' => 'old world',
19
+ 'world' => 'old hello'
20
+ }
21
+ end
22
+
23
+ let(:new_image) do
24
+ {
25
+ 'hello' => 'world',
26
+ 'world' => 'hello'
27
+ }
28
+ end
29
+
30
+ before do
31
+ allow(ActiveRecordStreams::Message)
32
+ .to receive(:new)
33
+ .with(described_class.table_name, :create, old_image, new_image)
34
+ .and_return(message)
35
+ end
36
+
37
+ it 'publishes event to streams with proper data' do
38
+ expect(stream)
39
+ .to receive(:publish)
40
+ .with(described_class.table_name, message)
41
+
42
+ subject._invoke_persistent_callbacks
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecord
4
+ Persistence.prepend(
5
+ Module.new do
6
+ private
7
+
8
+ ##
9
+ # destroy/destroy! methods should still call `delete_all` without
10
+ # callbacks, since they already serve their own callbacks
11
+
12
+ def destroy_row
13
+ relation_for_destroy.delete_all_without_callbacks
14
+ end
15
+ end
16
+ )
17
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../../../spec/mocks/active_record/persistence'
4
+ require_relative 'persistence'
5
+
6
+ RSpec.describe ActiveRecord::Persistence do
7
+ let(:relation_for_destroy) { double }
8
+
9
+ before do
10
+ allow(subject)
11
+ .to receive(:relation_for_destroy)
12
+ .and_return(relation_for_destroy)
13
+ end
14
+
15
+ subject do
16
+ Class.new do
17
+ include ActiveRecord::Persistence
18
+ end.new
19
+ end
20
+
21
+ describe 'destroy!' do
22
+ it 'calls original delete_all' do
23
+ expect(relation_for_destroy).to receive(:delete_all_without_callbacks)
24
+
25
+ subject.destroy!
26
+ end
27
+ end
28
+
29
+ describe 'destroy' do
30
+ it 'calls original delete_all' do
31
+ expect(relation_for_destroy).to receive(:delete_all_without_callbacks)
32
+
33
+ subject.destroy
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecord
4
+ Relation.prepend(
5
+ Module.new do
6
+ def self.prepended(object)
7
+ object.class_eval do
8
+ alias_method :delete_all_without_callbacks, :delete_all
9
+ alias_method :delete_all, :delete_all_with_callbacks
10
+ end
11
+ end
12
+
13
+ def update_all(updates)
14
+ find_each.inject(0) do |counter, record|
15
+ record.assign_attributes(updates)
16
+ counter += 1 if record.save(validate: false)
17
+
18
+ counter
19
+ end
20
+ end
21
+
22
+ ##
23
+ # Override `delete_all` method to serve callbacks.
24
+ # Change to this method also affects `delete` method.
25
+
26
+ # rubocop:disable Metrics/MethodLength
27
+ def delete_all_with_callbacks(conditions = nil)
28
+ return where(conditions).delete_all if conditions
29
+
30
+ find_each.inject(0) do |count, record|
31
+ wrap_delete_with_callbacks(record) do
32
+ deleted_count = where(
33
+ primary_key => record.attributes[primary_key]
34
+ ).delete_all_without_callbacks
35
+
36
+ if deleted_count == 1
37
+ record.instance_variable_set('@destroyed', true)
38
+ count += 1
39
+ end
40
+
41
+ count
42
+ end
43
+ end
44
+ end
45
+ # rubocop:enable Metrics/MethodLength
46
+
47
+ private
48
+
49
+ def wrap_delete_with_callbacks(record)
50
+ record.run_callbacks(:commit) do
51
+ record.run_callbacks(:destroy) do
52
+ ActiveRecord::Base.transaction do
53
+ yield
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
59
+ )
60
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../../../spec/mocks/active_record/relation'
4
+ require_relative 'relation'
5
+
6
+ RSpec.describe ActiveRecord::Relation do
7
+ let(:record) { double(assign_attributes: nil, save: nil, run_callbacks: nil, attributes: { 'id' => 1 }) }
8
+
9
+ before do
10
+ allow(subject).to receive(:where).and_return(subject)
11
+ allow(subject).to receive(:find_each).and_return([record])
12
+ end
13
+
14
+ describe '#update_all' do
15
+ it 'invokes save for each record' do
16
+ expect(record).to receive(:assign_attributes).with(a: 'b')
17
+ expect(record).to receive(:save).with(validate: false)
18
+
19
+ subject.update_all(a: 'b')
20
+ end
21
+ end
22
+
23
+ describe '#delete_all' do
24
+ before do
25
+ allow(subject).to receive(:primary_key).and_return('id')
26
+ end
27
+
28
+ it 'performs deletion invoking the callbacks' do
29
+ expect(record).to receive(:run_callbacks).with(:commit).and_yield
30
+ expect(record).to receive(:run_callbacks).with(:destroy).and_yield
31
+ expect(ActiveRecord::Base).to receive(:transaction).and_yield
32
+
33
+ expect(subject).to receive(:where).with('id' => 1).and_return(subject)
34
+ expect(subject).to receive(:delete_all_without_callbacks).and_return(1)
35
+
36
+ expect(record).to receive(:instance_variable_set).with('@destroyed', true)
37
+
38
+ subject.delete_all
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecordStreams
4
+ class Message
5
+ def initialize(table_name, action_type, old_image, new_image)
6
+ @table_name = table_name
7
+ @action_type = action_type
8
+ @old_image = old_image || new_image
9
+ @new_image = new_image
10
+ end
11
+
12
+ def json
13
+ {
14
+ TableName: @table_name,
15
+ ActionType: @action_type,
16
+ OldImage: @old_image,
17
+ NewImage: @new_image
18
+ }.to_json
19
+ end
20
+ end
21
+ end