sandthorn_sequel_projection 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.autotest +3 -0
- data/.gitignore +23 -0
- data/.rspec +2 -0
- data/.ruby-gemset +1 -0
- data/.ruby-version +1 -0
- data/.travis.yml +9 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +22 -0
- data/README.md +130 -0
- data/Rakefile +16 -0
- data/lib/sandthorn_sequel_projection.rb +57 -0
- data/lib/sandthorn_sequel_projection/cursor.rb +39 -0
- data/lib/sandthorn_sequel_projection/errors.rb +14 -0
- data/lib/sandthorn_sequel_projection/event_handler.rb +49 -0
- data/lib/sandthorn_sequel_projection/event_handler_collection.rb +27 -0
- data/lib/sandthorn_sequel_projection/lock.rb +83 -0
- data/lib/sandthorn_sequel_projection/manifest.rb +10 -0
- data/lib/sandthorn_sequel_projection/processed_events_tracker.rb +94 -0
- data/lib/sandthorn_sequel_projection/projection.rb +65 -0
- data/lib/sandthorn_sequel_projection/runner.rb +62 -0
- data/lib/sandthorn_sequel_projection/tasks.rb +14 -0
- data/lib/sandthorn_sequel_projection/utilities.rb +5 -0
- data/lib/sandthorn_sequel_projection/utilities/core_extensions/array_wrap.rb +13 -0
- data/lib/sandthorn_sequel_projection/utilities/null_proc.rb +9 -0
- data/lib/sandthorn_sequel_projection/version.rb +3 -0
- data/sandthorn_sequel_projection.gemspec +37 -0
- data/spec/cursor_spec.rb +44 -0
- data/spec/event_handler_collection_spec.rb +39 -0
- data/spec/event_handler_spec.rb +82 -0
- data/spec/integration/projection_spec.rb +126 -0
- data/spec/lock_spec.rb +141 -0
- data/spec/processed_events_tracker_spec.rb +79 -0
- data/spec/projection_spec.rb +91 -0
- data/spec/runner_spec.rb +32 -0
- data/spec/spec_helper.rb +23 -0
- data/spec/support/mock_event_store.rb +39 -0
- data/spec/test_data/event_data.json +1876 -0
- data/spec/utilities/null_proc_spec.rb +14 -0
- metadata +262 -0
@@ -0,0 +1,94 @@
|
|
1
|
+
require 'forwardable'
|
2
|
+
|
3
|
+
module SandthornSequelProjection
|
4
|
+
class ProcessedEventsTracker
|
5
|
+
extend Forwardable
|
6
|
+
|
7
|
+
def_delegator self, :table_name
|
8
|
+
def_delegators :db_connection, :transaction
|
9
|
+
|
10
|
+
attr_reader :db_connection, :identifier, :lock, :event_store
|
11
|
+
|
12
|
+
DEFAULT_TABLE_NAME = :processed_events_trackers
|
13
|
+
|
14
|
+
def initialize(identifier:, event_store:, db_connection: nil)
|
15
|
+
@identifier = identifier.to_s
|
16
|
+
@event_store = event_store
|
17
|
+
@db_connection = db_connection || SandthornSequelProjection.configuration.db_connection
|
18
|
+
@lock = Lock.new(identifier, @db_connection)
|
19
|
+
ensure_row
|
20
|
+
end
|
21
|
+
|
22
|
+
def with_lock
|
23
|
+
@lock.acquire do
|
24
|
+
yield
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
def process_events(&block)
|
29
|
+
with_lock do
|
30
|
+
cursor = Cursor.new(after_sequence_number: last_processed_sequence_number, event_store: event_store)
|
31
|
+
events = cursor.get_batch
|
32
|
+
transaction do
|
33
|
+
until(events.empty?)
|
34
|
+
block.call(events)
|
35
|
+
events = cursor.get_batch
|
36
|
+
end
|
37
|
+
write_sequence_number(cursor.last_sequence_number)
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
def last_processed_sequence_number
|
43
|
+
row[:last_processed_sequence_number]
|
44
|
+
end
|
45
|
+
|
46
|
+
def table
|
47
|
+
db_connection[table_name]
|
48
|
+
end
|
49
|
+
|
50
|
+
def row_exists?
|
51
|
+
!row.nil?
|
52
|
+
end
|
53
|
+
|
54
|
+
def row
|
55
|
+
table.where(identifier: identifier).first
|
56
|
+
end
|
57
|
+
|
58
|
+
def reset
|
59
|
+
with_lock do
|
60
|
+
write_sequence_number(0)
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
def self.table_name
|
65
|
+
DEFAULT_TABLE_NAME
|
66
|
+
end
|
67
|
+
|
68
|
+
def self.migrate!(db_connection)
|
69
|
+
db_connection.create_table?(table_name) do
|
70
|
+
String :identifier
|
71
|
+
Integer :last_processed_sequence_number, default: 0
|
72
|
+
DateTime :locked_at, null: true
|
73
|
+
end
|
74
|
+
db_connection.add_index(table_name, :identifier, unique: true)
|
75
|
+
rescue Exception => e
|
76
|
+
raise Errors::MigrationError, e
|
77
|
+
end
|
78
|
+
|
79
|
+
private
|
80
|
+
|
81
|
+
def write_sequence_number(number)
|
82
|
+
table.where(identifier: identifier).update(last_processed_sequence_number: number)
|
83
|
+
end
|
84
|
+
|
85
|
+
def ensure_row
|
86
|
+
create_row unless row_exists?
|
87
|
+
end
|
88
|
+
|
89
|
+
def create_row
|
90
|
+
table.insert(identifier: identifier)
|
91
|
+
end
|
92
|
+
|
93
|
+
end
|
94
|
+
end
|
@@ -0,0 +1,65 @@
|
|
1
|
+
require 'forwardable'
|
2
|
+
|
3
|
+
module SandthornSequelProjection
|
4
|
+
class Projection
|
5
|
+
extend Forwardable
|
6
|
+
include SimpleMigrator::Migratable
|
7
|
+
|
8
|
+
def_delegators self, :identifier, :event_store
|
9
|
+
def_delegators :tracker, :last_processed_sequence_number
|
10
|
+
|
11
|
+
attr_reader :db_connection, :event_handlers, :tracker
|
12
|
+
|
13
|
+
def initialize(db_connection = nil)
|
14
|
+
@db_connection = db_connection || SandthornSequelProjection.configuration.db_connection
|
15
|
+
@tracker = ProcessedEventsTracker.new(
|
16
|
+
identifier: identifier,
|
17
|
+
db_connection: @db_connection,
|
18
|
+
event_store: event_store)
|
19
|
+
@event_handlers = self.class.event_handlers
|
20
|
+
end
|
21
|
+
|
22
|
+
def update!
|
23
|
+
tracker.process_events do |batch|
|
24
|
+
event_handlers.handle(self, batch)
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
def migrator
|
29
|
+
SimpleMigrator.migrator(db_connection)
|
30
|
+
end
|
31
|
+
|
32
|
+
class << self
|
33
|
+
attr_reader :event_store_name
|
34
|
+
|
35
|
+
def event_store(event_store = nil)
|
36
|
+
if event_store
|
37
|
+
@event_store_name = event_store
|
38
|
+
else
|
39
|
+
find_event_store
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
def find_event_store
|
44
|
+
SandthornSequelProjection.find_event_store(@event_store_name)
|
45
|
+
end
|
46
|
+
|
47
|
+
attr_reader :event_store_name
|
48
|
+
attr_accessor :event_handlers
|
49
|
+
|
50
|
+
def define_event_handlers
|
51
|
+
@event_handlers ||= EventHandlerCollection.new
|
52
|
+
yield(@event_handlers)
|
53
|
+
end
|
54
|
+
|
55
|
+
def identifier
|
56
|
+
self.name.gsub(/::/, '_').
|
57
|
+
gsub(/([A-Z]+)([A-Z][a-z])/,'\1_\2').
|
58
|
+
gsub(/([a-z\d])([A-Z])/,'\1_\2').
|
59
|
+
tr("-", "_").
|
60
|
+
downcase
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
end
|
65
|
+
end
|
@@ -0,0 +1,62 @@
|
|
1
|
+
module SandthornSequelProjection
|
2
|
+
class Runner
|
3
|
+
|
4
|
+
DEFAULT_INTERVAL = 0.5
|
5
|
+
|
6
|
+
attr_reader :manifest, :interval
|
7
|
+
|
8
|
+
def initialize(manifest, interval = DEFAULT_INTERVAL)
|
9
|
+
@manifest = manifest
|
10
|
+
@interval = interval
|
11
|
+
end
|
12
|
+
|
13
|
+
def run(infinite = true)
|
14
|
+
@projections = manifest.projections.map do |projection_class|
|
15
|
+
projection_class.new(db_connection)
|
16
|
+
end
|
17
|
+
migrate!
|
18
|
+
if infinite
|
19
|
+
start_loop
|
20
|
+
else
|
21
|
+
loop_once
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
private
|
26
|
+
|
27
|
+
def start_loop
|
28
|
+
while true
|
29
|
+
loop_once
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
def loop_once
|
34
|
+
@projections.each do |projection|
|
35
|
+
projection.update!
|
36
|
+
end
|
37
|
+
sleep(interval)
|
38
|
+
end
|
39
|
+
|
40
|
+
def db_connection
|
41
|
+
SandthornSequelProjection.configuration.db_connection
|
42
|
+
end
|
43
|
+
|
44
|
+
def migrate!
|
45
|
+
@projections.each(&:migrate!)
|
46
|
+
end
|
47
|
+
|
48
|
+
end
|
49
|
+
|
50
|
+
class CircularQueue < Queue
|
51
|
+
|
52
|
+
# = CircularQueue
|
53
|
+
# Automatically pushes popped elements to the back of the queue
|
54
|
+
# In other words, can never be emptied by use of pop
|
55
|
+
def pop
|
56
|
+
super.tap do |el|
|
57
|
+
self << el
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
end
|
62
|
+
end
|
@@ -0,0 +1,14 @@
|
|
1
|
+
require 'rake/dsl_definition'
|
2
|
+
|
3
|
+
module SandthornSequelProjections
|
4
|
+
class RakeTasks
|
5
|
+
include Rake::DSL if defined? Rake::DSL
|
6
|
+
def install
|
7
|
+
namespace :sandthorn_projections do
|
8
|
+
task :init do
|
9
|
+
SandthornSequelProjections.start
|
10
|
+
end
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
@@ -0,0 +1,13 @@
|
|
1
|
+
# Copied from ActiveSupport
|
2
|
+
# https://github.com/rails/rails/blob/master/activesupport/lib/active_support/core_ext/array/wrap.rb
|
3
|
+
class Array
|
4
|
+
def self.wrap(object)
|
5
|
+
if object.nil?
|
6
|
+
[]
|
7
|
+
elsif object.respond_to?(:to_ary)
|
8
|
+
object.to_ary || [object]
|
9
|
+
else
|
10
|
+
[object]
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
lib = File.expand_path('../lib', __FILE__)
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
+
require 'sandthorn_sequel_projection/version'
|
5
|
+
|
6
|
+
Gem::Specification.new do |spec|
|
7
|
+
spec.name = "sandthorn_sequel_projection"
|
8
|
+
spec.version = SandthornSequelProjection::VERSION
|
9
|
+
spec.authors = ["Lars Krantz"]
|
10
|
+
spec.email = ["lars.krantz@alaz.se"]
|
11
|
+
spec.summary = %q{Helps creating sql projections from sandthorn events}
|
12
|
+
spec.description = spec.summary
|
13
|
+
spec.homepage = ""
|
14
|
+
spec.license = "MIT"
|
15
|
+
|
16
|
+
spec.files = `git ls-files -z`.split("\x0")
|
17
|
+
spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
|
18
|
+
spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
|
19
|
+
spec.require_paths = ["lib"]
|
20
|
+
|
21
|
+
spec.required_ruby_version = ">= 2.0"
|
22
|
+
|
23
|
+
spec.add_development_dependency "bundler", "~> 1.6"
|
24
|
+
spec.add_development_dependency "rake"
|
25
|
+
spec.add_development_dependency "rspec"
|
26
|
+
spec.add_development_dependency "sandthorn_driver_sequel"
|
27
|
+
spec.add_development_dependency "awesome_print"
|
28
|
+
spec.add_development_dependency "pry"
|
29
|
+
spec.add_development_dependency "sqlite3"
|
30
|
+
spec.add_development_dependency "codeclimate-test-reporter"
|
31
|
+
|
32
|
+
spec.add_runtime_dependency "sandthorn", "~> 0.6"
|
33
|
+
spec.add_runtime_dependency "sandthorn_event_filter", "~> 0.0.4"
|
34
|
+
spec.add_runtime_dependency "sequel"
|
35
|
+
spec.add_runtime_dependency "simple_migrator"
|
36
|
+
|
37
|
+
end
|
data/spec/cursor_spec.rb
ADDED
@@ -0,0 +1,44 @@
|
|
1
|
+
module SandthornSequelProjection
|
2
|
+
describe Cursor do
|
3
|
+
|
4
|
+
let(:first_page) { [ { sequence_number: 1 } ] }
|
5
|
+
let(:second_page) { [ { sequence_number: 2 } ] }
|
6
|
+
let(:third_page) { [] }
|
7
|
+
let(:event_store) do
|
8
|
+
store = Object.new
|
9
|
+
env = self
|
10
|
+
store.tap do |store|
|
11
|
+
store.define_singleton_method(:get_events) do |after_sequence_number: 0, take: 1|
|
12
|
+
case after_sequence_number
|
13
|
+
when 0
|
14
|
+
env.first_page
|
15
|
+
when 1
|
16
|
+
env.second_page
|
17
|
+
else
|
18
|
+
env.third_page
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
let(:cursor) { Cursor.new(after_sequence_number: 0, batch_size: 1, event_store: event_store) }
|
24
|
+
|
25
|
+
it "has the correct starting sequence number and batch size" do
|
26
|
+
expect(cursor.batch_size).to eq(1)
|
27
|
+
expect(cursor.last_sequence_number).to eq(0)
|
28
|
+
end
|
29
|
+
|
30
|
+
describe "#get_batch" do
|
31
|
+
describe "sequential calls" do
|
32
|
+
it "returns pages until empty and keeps track of seen sequence numbers" do
|
33
|
+
expect(cursor.get_batch.events).to eq(first_page)
|
34
|
+
expect(cursor.last_sequence_number).to eq(first_page.last[:sequence_number])
|
35
|
+
expect(cursor.get_batch.events).to eq(second_page)
|
36
|
+
expect(cursor.last_sequence_number).to eq(second_page.last[:sequence_number])
|
37
|
+
expect(cursor.get_batch.events).to eq(third_page)
|
38
|
+
expect(cursor.last_sequence_number).to eq(second_page.last[:sequence_number])
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
end
|
44
|
+
end
|
@@ -0,0 +1,39 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
module SandthornSequelProjection
|
4
|
+
describe EventHandlerCollection do
|
5
|
+
let(:collection) { EventHandlerCollection.new }
|
6
|
+
let(:projection) { Object.new }
|
7
|
+
|
8
|
+
describe "#add" do
|
9
|
+
it "adds the handler to the collection" do
|
10
|
+
handler_data = :foo
|
11
|
+
collection.define(handler_data)
|
12
|
+
expect(collection.handlers.length).to eq(1)
|
13
|
+
handler = collection.handlers.first
|
14
|
+
expect(handler.message).to eq(:foo)
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
describe "#handle" do
|
19
|
+
it "calls handle on each handler for every event, in order" do
|
20
|
+
events = [1,2]
|
21
|
+
handler1 = :foo
|
22
|
+
handler2 = :bar
|
23
|
+
handlers = [handler1, handler2]
|
24
|
+
handlers.each do |handler|
|
25
|
+
collection.define(handler)
|
26
|
+
end
|
27
|
+
collection.handlers.each do |handler|
|
28
|
+
expect(handler).to receive(:handle).ordered.with(projection, 1).once
|
29
|
+
end
|
30
|
+
collection.handlers.each do |handler|
|
31
|
+
expect(handler).to receive(:handle).ordered.with(projection, 2).once
|
32
|
+
end
|
33
|
+
collection.handle(projection, events)
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
|
38
|
+
end
|
39
|
+
end
|
@@ -0,0 +1,82 @@
|
|
1
|
+
require "spec_helper"
|
2
|
+
|
3
|
+
module SandthornSequelProjection
|
4
|
+
describe EventHandler do
|
5
|
+
|
6
|
+
class TestProjection
|
7
|
+
def foo(event); event; end
|
8
|
+
end
|
9
|
+
|
10
|
+
let(:projection) { TestProjection.new }
|
11
|
+
let(:simple_handler) { EventHandler.new(:foo)}
|
12
|
+
|
13
|
+
describe "::initialize" do
|
14
|
+
|
15
|
+
context "when given just a symbol" do
|
16
|
+
|
17
|
+
it "creates a handler with an empty filter" do
|
18
|
+
expect(simple_handler.filter).to be_empty
|
19
|
+
end
|
20
|
+
|
21
|
+
it "creates a handler with the proper method handle" do
|
22
|
+
expect(simple_handler.message).to eq(:foo)
|
23
|
+
end
|
24
|
+
|
25
|
+
end
|
26
|
+
|
27
|
+
context "when given filters" do
|
28
|
+
context "and given singular keywords" do
|
29
|
+
let(:handler) { EventHandler.new(foo: { aggregate_type: "FooBar", event_name: "new" })}
|
30
|
+
let(:extractor) { handler.filter.matchers.matchers.first }
|
31
|
+
let(:matching_event) { { aggregate_type: "FooBar", event_name: "new" } }
|
32
|
+
|
33
|
+
it "creates a handler with an extract filter" do
|
34
|
+
expect(extractor).to be_a_kind_of(SandthornEventFilter::Matchers::Extract)
|
35
|
+
expect(extractor).to match(matching_event)
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
context "when given plural keywords" do
|
40
|
+
let(:handler) { EventHandler.new(foo: { aggregate_types: ["FooBar"], event_names: ["new"] })}
|
41
|
+
let(:extractor) { handler.filter.matchers.matchers.first }
|
42
|
+
let(:matching_event) { { aggregate_type: "FooBar", event_name: "new" } }
|
43
|
+
|
44
|
+
it "creates the correct handler" do
|
45
|
+
expect(extractor).to be_a_kind_of(SandthornEventFilter::Matchers::Extract)
|
46
|
+
expect(extractor).to match(matching_event)
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
describe "#handle" do
|
53
|
+
|
54
|
+
let(:event) { {foo: :bar} }
|
55
|
+
|
56
|
+
context "when given just a symbol" do
|
57
|
+
it "always calls the handler method" do
|
58
|
+
input = {foo: :bar}
|
59
|
+
expect(simple_handler.handle(projection, event)).to eq(event)
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
context "when given a symbol and filter" do
|
64
|
+
let(:handler) { EventHandler.new(foo: { aggregate_type: "FooBar", event_name: "new" })}
|
65
|
+
context "when the filter matches" do
|
66
|
+
it "calls the method" do
|
67
|
+
allow(handler.filter).to receive(:match?) { true }
|
68
|
+
expect(handler.handle(projection, event)).to eq(event)
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
context "when the filter doesn't match" do
|
73
|
+
it "doesn't call the method" do
|
74
|
+
allow(handler.filter).to receive(:match?) { false }
|
75
|
+
expect(handler.handle(projection, event)).to be_falsey
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|