active_record_streams 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.github/PULL_REQUEST_TEMPLATE.md +35 -0
- data/.gitignore +12 -0
- data/.rspec +5 -0
- data/.rubocop.yml +10 -0
- data/.travis.yml +12 -0
- data/CHANGELOG.md +13 -0
- data/Gemfile +7 -0
- data/Gemfile.lock +96 -0
- data/MIT-LICENSE +20 -0
- data/README.md +168 -0
- data/Rakefile +8 -0
- data/active_record_streams.gemspec +55 -0
- data/lib/active_record_streams.rb +26 -0
- data/lib/active_record_streams/config.rb +15 -0
- data/lib/active_record_streams/config_spec.rb +32 -0
- data/lib/active_record_streams/credentials.rb +31 -0
- data/lib/active_record_streams/credentials_spec.rb +79 -0
- data/lib/active_record_streams/extensions/active_record/base.rb +41 -0
- data/lib/active_record_streams/extensions/active_record/base_spec.rb +45 -0
- data/lib/active_record_streams/extensions/active_record/persistence.rb +17 -0
- data/lib/active_record_streams/extensions/active_record/persistence_spec.rb +36 -0
- data/lib/active_record_streams/extensions/active_record/relation.rb +60 -0
- data/lib/active_record_streams/extensions/active_record/relation_spec.rb +41 -0
- data/lib/active_record_streams/message.rb +21 -0
- data/lib/active_record_streams/message_spec.rb +29 -0
- data/lib/active_record_streams/publishers/http_stream.rb +58 -0
- data/lib/active_record_streams/publishers/http_stream_spec.rb +73 -0
- data/lib/active_record_streams/publishers/kinesis_client.rb +26 -0
- data/lib/active_record_streams/publishers/kinesis_client_spec.rb +49 -0
- data/lib/active_record_streams/publishers/kinesis_stream.rb +64 -0
- data/lib/active_record_streams/publishers/kinesis_stream_spec.rb +93 -0
- data/lib/active_record_streams/publishers/sns_client.rb +25 -0
- data/lib/active_record_streams/publishers/sns_client_spec.rb +46 -0
- data/lib/active_record_streams/publishers/sns_stream.rb +52 -0
- data/lib/active_record_streams/publishers/sns_stream_spec.rb +89 -0
- data/lib/active_record_streams/version.rb +5 -0
- data/lib/active_record_streams/version_spec.rb +9 -0
- data/lib/active_record_streams_spec.rb +21 -0
- 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
|