jouba 0.1.0 → 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/Gemfile +3 -2
- data/Gemfile.lock +12 -4
- data/README.md +113 -0
- data/VERSION +1 -1
- data/jouba.gemspec +20 -13
- data/lib/jouba.rb +20 -46
- data/lib/jouba/aggregate.rb +51 -39
- data/lib/jouba/cache.rb +40 -0
- data/lib/jouba/event.rb +27 -7
- data/lib/jouba/key.rb +11 -0
- data/lib/jouba/store.rb +76 -0
- data/spec/integration/customer_spec.rb +83 -0
- data/spec/lib/jouba/aggregate_spec.rb +88 -47
- data/spec/lib/jouba/cache_spec.rb +0 -0
- data/spec/lib/jouba/event_spec.rb +74 -10
- data/spec/lib/jouba/key_spec.rb +32 -0
- data/spec/lib/jouba_spec.rb +36 -182
- data/spec/spec_helper.rb +0 -7
- metadata +28 -10
- data/README.rdoc +0 -19
- data/lib/jouba/exceptions.rb +0 -3
- data/lib/jouba/stores.rb +0 -27
data/lib/jouba/cache.rb
ADDED
@@ -0,0 +1,40 @@
|
|
1
|
+
require 'forwardable'
|
2
|
+
|
3
|
+
module Jouba
|
4
|
+
module Cache
|
5
|
+
class Null
|
6
|
+
def fetch(_)
|
7
|
+
yield
|
8
|
+
end
|
9
|
+
|
10
|
+
def refresh(_, _)
|
11
|
+
yield
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
class Memory
|
16
|
+
extend Forwardable
|
17
|
+
|
18
|
+
attr_reader :store
|
19
|
+
|
20
|
+
def_delegators :store, :set, :get, :persist
|
21
|
+
|
22
|
+
def initialize
|
23
|
+
@store = MemoryStore.new
|
24
|
+
end
|
25
|
+
|
26
|
+
def fetch(key)
|
27
|
+
get(key) || yield.tap { |value| store.set(key, value) }
|
28
|
+
end
|
29
|
+
|
30
|
+
def refresh(key, value)
|
31
|
+
store.set(key, value)
|
32
|
+
yield
|
33
|
+
end
|
34
|
+
|
35
|
+
def self.load(file_path)
|
36
|
+
@store = MemoryStore.load(file_path)
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
data/lib/jouba/event.rb
CHANGED
@@ -1,13 +1,33 @@
|
|
1
1
|
module Jouba
|
2
|
-
class Event < Hashie::
|
3
|
-
|
2
|
+
class Event < Hashie::Trash
|
3
|
+
property :key, required: true
|
4
|
+
property :name, required: true
|
5
|
+
property :data, required: true
|
6
|
+
property :uuid, default: ->(e) { e.send(:raw_uuid).to_s }
|
7
|
+
property :version, default: ->(e) { e.send(:raw_uuid).version }
|
8
|
+
property :timestamp, default: ->(e) { e.send(:raw_uuid).timestamp }
|
4
9
|
|
5
|
-
|
6
|
-
|
7
|
-
|
10
|
+
def self.serialize(event)
|
11
|
+
event.to_h
|
12
|
+
end
|
13
|
+
|
14
|
+
def self.deserialize(serialized_event)
|
15
|
+
new(serialized_event)
|
16
|
+
end
|
17
|
+
|
18
|
+
def self.stream(key, params)
|
19
|
+
Jouba.Store.get(key, params).map { |event| Event.deserialize(event) }
|
20
|
+
end
|
21
|
+
|
22
|
+
def track
|
23
|
+
Jouba.Store.set(key, Event.serialize(self))
|
24
|
+
end
|
25
|
+
alias_method :save, :track
|
26
|
+
|
27
|
+
private
|
8
28
|
|
9
|
-
def
|
10
|
-
|
29
|
+
def raw_uuid
|
30
|
+
@raw_uuid ||= self[:uuid].nil? ? UUID.new : UUID.new(self[:uuid])
|
11
31
|
end
|
12
32
|
end
|
13
33
|
end
|
data/lib/jouba/key.rb
ADDED
data/lib/jouba/store.rb
ADDED
@@ -0,0 +1,76 @@
|
|
1
|
+
require 'yaml'
|
2
|
+
require 'forwardable'
|
3
|
+
|
4
|
+
module Jouba
|
5
|
+
class MemoryStore
|
6
|
+
attr_reader :db
|
7
|
+
|
8
|
+
def initialize
|
9
|
+
flush
|
10
|
+
end
|
11
|
+
|
12
|
+
def get(key, _ = {})
|
13
|
+
db[key].nil? ? nil : deserialize(db[key])
|
14
|
+
end
|
15
|
+
|
16
|
+
def set(key, value)
|
17
|
+
db[key] = serialize(value)
|
18
|
+
end
|
19
|
+
|
20
|
+
def delete(key)
|
21
|
+
db.delete(key)
|
22
|
+
end
|
23
|
+
|
24
|
+
def flush
|
25
|
+
@db = {}
|
26
|
+
end
|
27
|
+
|
28
|
+
def persist(file_path)
|
29
|
+
File.open(file_path, 'w') { |file| file.write @db.to_yaml }
|
30
|
+
end
|
31
|
+
|
32
|
+
def self.load(file_path)
|
33
|
+
new.tap { |store| store.instance_variable_set('@db', YAML.load_file(file_path)) }
|
34
|
+
end
|
35
|
+
|
36
|
+
protected
|
37
|
+
|
38
|
+
def deserialize(data)
|
39
|
+
YAML.load(data)
|
40
|
+
end
|
41
|
+
|
42
|
+
def serialize(data)
|
43
|
+
YAML.dump(data)
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
class EventStore < MemoryStore
|
48
|
+
class Collection
|
49
|
+
extend Forwardable
|
50
|
+
include Enumerable
|
51
|
+
|
52
|
+
attr_reader :collection
|
53
|
+
def_delegators :collection, :each
|
54
|
+
|
55
|
+
def initialize(collection)
|
56
|
+
@collection = collection
|
57
|
+
end
|
58
|
+
|
59
|
+
def since(time)
|
60
|
+
Collection.new collection.select { |item| item.timestamp <= time }
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
def get(key, _ = {})
|
65
|
+
Collection.new db[key].map { |item| deserialize(item) }
|
66
|
+
end
|
67
|
+
|
68
|
+
def set(key, value)
|
69
|
+
db[key].push serialize(value)
|
70
|
+
end
|
71
|
+
|
72
|
+
def flush
|
73
|
+
@db = Hash.new { |h, k| h[k] = [] }
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
@@ -0,0 +1,83 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
require 'jouba/aggregate'
|
3
|
+
|
4
|
+
describe 'Aggregate' do
|
5
|
+
class Customer < Hashie::Dash
|
6
|
+
include Jouba::Aggregate.new(prefix: :on)
|
7
|
+
property :uuid
|
8
|
+
property :name
|
9
|
+
|
10
|
+
def self.create(attributes)
|
11
|
+
Customer.new(uuid: SecureRandom.uuid).tap do |customer|
|
12
|
+
customer.create(attributes.merge(uuid: customer.uuid))
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
def create(attributes)
|
17
|
+
emit(:created, attributes)
|
18
|
+
end
|
19
|
+
|
20
|
+
private
|
21
|
+
|
22
|
+
def on_created(attributes)
|
23
|
+
update_attributes!(attributes)
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
let(:name) { 'foo' }
|
28
|
+
|
29
|
+
describe 'create new customer' do
|
30
|
+
subject { Customer.create(name: name) }
|
31
|
+
|
32
|
+
it 'creates a customer' do
|
33
|
+
expect(subject).to be_a Customer
|
34
|
+
expect(subject.name).to eq name
|
35
|
+
end
|
36
|
+
|
37
|
+
it 'increased the stream stack' do
|
38
|
+
expect(Customer.stream(subject.uuid).size).to eq 1
|
39
|
+
end
|
40
|
+
|
41
|
+
it 'it findable' do
|
42
|
+
expect(Customer.find(subject.uuid)).to eq subject
|
43
|
+
end
|
44
|
+
|
45
|
+
describe 'with cache' do
|
46
|
+
before do
|
47
|
+
Jouba.config.Cache = Jouba::Cache::Memory.new
|
48
|
+
end
|
49
|
+
|
50
|
+
after do
|
51
|
+
Jouba.config.Cache = Jouba::Cache::Null.new
|
52
|
+
end
|
53
|
+
|
54
|
+
context 'when there is something in the cache' do
|
55
|
+
before { Customer.create(name: name) }
|
56
|
+
after { 10.times { Customer.find(subject.uuid) } }
|
57
|
+
|
58
|
+
it 'dont uses the cache' do
|
59
|
+
expect(Customer).not_to receive(:replay)
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
context 'when there is nothing in the cache' do
|
64
|
+
let(:customer) { Customer.create(name: name) }
|
65
|
+
let(:key) { Customer.key_from_uuid(customer.uuid) }
|
66
|
+
let(:uuid) { customer.uuid }
|
67
|
+
|
68
|
+
before do
|
69
|
+
expect(Jouba.Cache.get(key)).not_to eq nil
|
70
|
+
Jouba.Cache.store.flush
|
71
|
+
expect(Jouba.Cache.get(key)).to eq nil
|
72
|
+
end
|
73
|
+
|
74
|
+
after { 10.times { Customer.find(uuid) } }
|
75
|
+
|
76
|
+
it 'uses the cache once' do
|
77
|
+
expect { |b| Jouba.Cache.fetch(key, &b) }.to yield_control.exactly(1)
|
78
|
+
expect(Jouba.Cache).to receive(:fetch).exactly(10)
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
@@ -1,74 +1,115 @@
|
|
1
|
-
require 'ostruct'
|
2
1
|
require 'spec_helper'
|
2
|
+
require 'jouba/aggregate'
|
3
3
|
|
4
4
|
describe Jouba::Aggregate do
|
5
|
-
let(:
|
5
|
+
let(:uuid) { '123' }
|
6
|
+
let(:attributes) { { name: 'bar' } }
|
7
|
+
let(:name) { :created }
|
8
|
+
let(:event) { Jouba::Event.new(key: target.to_key, name: name, data: attributes) }
|
9
|
+
let(:listener) { double(:listener) }
|
6
10
|
|
7
|
-
|
11
|
+
let(:target_class) do
|
12
|
+
Class.new(OpenStruct) do
|
13
|
+
include Jouba::Aggregate.new(prefix: :on)
|
14
|
+
include Wisper::Publisher
|
8
15
|
|
9
|
-
|
10
|
-
|
16
|
+
def create(attributes)
|
17
|
+
emit(:created, attributes)
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
let(:target) { target_class.new(uuid: uuid) }
|
22
|
+
|
23
|
+
describe '.initialize' do
|
24
|
+
it 'has instance methods of an aggreate' do
|
25
|
+
described_class::InstanceMethods.public_instance_methods.each do |meth|
|
26
|
+
expect(target_class.new).to respond_to(meth)
|
27
|
+
end
|
28
|
+
end
|
11
29
|
|
12
|
-
it '
|
13
|
-
|
14
|
-
|
30
|
+
it 'has class methods of an aggreate' do
|
31
|
+
described_class::ClassMethods.public_instance_methods.each do |meth|
|
32
|
+
expect(target_class).to respond_to(meth)
|
33
|
+
end
|
15
34
|
end
|
16
35
|
end
|
17
36
|
|
18
|
-
describe '
|
19
|
-
|
20
|
-
let(:uuid) { '123' }
|
21
|
-
let(:events) { [double(:event)] }
|
37
|
+
describe '#emit(name, *args)' do
|
38
|
+
after { target.create(attributes) }
|
22
39
|
|
23
|
-
|
24
|
-
expect(
|
25
|
-
|
26
|
-
expect(
|
27
|
-
aggregate_class.build_from_events(uuid, events)
|
40
|
+
before do
|
41
|
+
expect(Jouba.Event).to receive(:new)
|
42
|
+
.with(key: target.to_key, name: name, data: [attributes]).and_return(event)
|
43
|
+
expect(target).to receive(:"on_#{name}").with(*attributes)
|
28
44
|
end
|
29
45
|
|
30
|
-
|
31
|
-
|
46
|
+
it 'apply the event' do
|
47
|
+
expect(target).to receive(:apply_event).with(event).and_call_original
|
48
|
+
end
|
32
49
|
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
end
|
50
|
+
it 'refresh the cache' do
|
51
|
+
expect(Jouba.Cache).to receive(:refresh).with(target.to_key, target)
|
52
|
+
.and_yield.and_call_original
|
53
|
+
end
|
38
54
|
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
expect(aggregate).to receive(:[]=).with(:uuid, uuid)
|
43
|
-
expect(aggregate).to receive(:subscribe).with(observer)
|
44
|
-
aggregate_class.build_from_events(uuid, events)
|
45
|
-
end
|
55
|
+
it 'publish an event' do
|
56
|
+
target.subscribe(listener, prefix: :on)
|
57
|
+
expect(listener).to receive(:"on_#{name}").with(attributes)
|
46
58
|
end
|
59
|
+
end
|
47
60
|
|
61
|
+
describe '#to_key' do
|
62
|
+
let(:uuid) { 123 }
|
63
|
+
it 'delegates to the class method' do
|
64
|
+
expect(target_class).to receive(:key_from_uuid).with(target.uuid)
|
65
|
+
target.to_key
|
66
|
+
end
|
48
67
|
end
|
49
68
|
|
50
|
-
describe '
|
69
|
+
describe '#replay(event)' do
|
70
|
+
after { target.replay(event) }
|
71
|
+
it 'calls the callback_method with the right params' do
|
72
|
+
expect(target).to receive(:"on_#{name}").with(*attributes)
|
73
|
+
end
|
51
74
|
end
|
52
75
|
|
53
|
-
describe '
|
54
|
-
let(:
|
76
|
+
describe '.replay(events)' do
|
77
|
+
let(:events) { [event, event] }
|
78
|
+
|
79
|
+
after { target_class.replay(events) }
|
80
|
+
before { expect(target_class).to receive(:new).and_return(target) }
|
55
81
|
|
56
|
-
it '
|
57
|
-
expect(
|
82
|
+
it 'create a new instance of the aggregate and apply all the events' do
|
83
|
+
expect(target).to receive(:replay).with(event).exactly(2).times
|
58
84
|
end
|
59
85
|
end
|
60
86
|
|
61
|
-
describe '
|
62
|
-
let(:
|
63
|
-
let(:
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
expect(
|
69
|
-
expect(Jouba
|
70
|
-
|
71
|
-
|
87
|
+
describe '.find(uuid)' do
|
88
|
+
let(:key) { target_class.key_from_uuid(uuid) }
|
89
|
+
let(:stream) { [] }
|
90
|
+
after { target_class.find(uuid) }
|
91
|
+
|
92
|
+
it 'goes through the cache' do
|
93
|
+
expect(target_class).to receive(:stream).with(uuid).and_return(stream)
|
94
|
+
expect(target_class).to receive(:replay).with(stream)
|
95
|
+
expect(Jouba.Cache).to receive(:fetch).with(key).and_yield.and_call_original
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
describe '.stream(uuid)' do
|
100
|
+
let(:key) { target_class.key_from_uuid(uuid) }
|
101
|
+
let(:params) { {} }
|
102
|
+
after { target_class.stream(uuid, params) }
|
103
|
+
it 'returns the stream from the EventStore' do
|
104
|
+
expect(Jouba.Event).to receive(:stream).with(key, params)
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
108
|
+
describe '.key_from_uuid(uuid)' do
|
109
|
+
after { target_class.key_from_uuid(uuid) }
|
110
|
+
|
111
|
+
it 'delegates to the configured key structure' do
|
112
|
+
expect(Jouba.Key).to receive(:serialize).with(target_class.name, uuid)
|
72
113
|
end
|
73
114
|
end
|
74
115
|
end
|
File without changes
|
@@ -1,21 +1,85 @@
|
|
1
1
|
require 'spec_helper'
|
2
2
|
|
3
3
|
describe Jouba::Event do
|
4
|
-
let(:
|
5
|
-
let(:
|
6
|
-
let(:
|
4
|
+
let(:key) { 'User.1' }
|
5
|
+
let(:name) { 'created' }
|
6
|
+
let(:data) { { fname: 'John', lname: 'Doe' } }
|
7
|
+
let(:uuid) { UUID.new }
|
8
|
+
let(:attributes) do
|
9
|
+
{ key: key, name: name, data: data }
|
10
|
+
end
|
11
|
+
|
12
|
+
subject { described_class.new(attributes) }
|
7
13
|
|
8
|
-
describe '.
|
14
|
+
describe '.new(attributes)' do
|
9
15
|
before do
|
10
|
-
|
16
|
+
allow(UUID).to receive(:new).at_least(:once).and_return(uuid)
|
11
17
|
end
|
12
18
|
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
expect(subject.
|
19
|
+
it 'sets the attributes and sets defaults for the other values' do
|
20
|
+
expect(subject.uuid).to eq uuid.to_s
|
21
|
+
expect(subject.version).to eq uuid.version
|
22
|
+
expect(subject.timestamp).to eq uuid.timestamp
|
23
|
+
expect(subject.key).to eq key
|
24
|
+
expect(subject.name).to eq name
|
17
25
|
expect(subject.data).to eq data
|
18
|
-
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
describe '.serialize(event)' do
|
30
|
+
before do
|
31
|
+
allow(UUID).to receive(:new).at_least(:once).and_return(uuid)
|
32
|
+
end
|
33
|
+
|
34
|
+
let(:event) { described_class.new(attributes) }
|
35
|
+
let(:expected_serialized) do
|
36
|
+
{
|
37
|
+
key: key,
|
38
|
+
name: name,
|
39
|
+
data: data,
|
40
|
+
uuid: uuid.to_s,
|
41
|
+
version: uuid.version,
|
42
|
+
timestamp: uuid.timestamp
|
43
|
+
}
|
44
|
+
end
|
45
|
+
|
46
|
+
subject { described_class.serialize(event) }
|
47
|
+
|
48
|
+
it 'serializes into a hash' do
|
49
|
+
expect(subject).to eq expected_serialized
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
describe '.deserialize(serialized_event)' do
|
54
|
+
let(:event) { described_class.new(attributes) }
|
55
|
+
let(:serialized_event) { described_class.serialize(event) }
|
56
|
+
subject { described_class.deserialize(serialized_event) }
|
57
|
+
|
58
|
+
it 'rebuild an event based on the serialized version' do
|
59
|
+
expect(subject).to eq event
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
describe '.stream(key, params)' do
|
64
|
+
let(:stream) { [described_class.new(attributes), described_class.new(attributes)] }
|
65
|
+
let(:key) { 'key' }
|
66
|
+
let(:params) { { lname: 'foo' } }
|
67
|
+
let(:serialized_stream) { stream.map { |item| described_class.serialize(item) } }
|
68
|
+
|
69
|
+
before { expect(Jouba.Store).to receive(:get).with(key, params).and_return(serialized_stream) }
|
70
|
+
|
71
|
+
it 'returns a stream of events' do
|
72
|
+
expect(described_class.stream(key, params)).to eq stream
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
describe '#track' do
|
77
|
+
let(:event) { described_class.new(attributes) }
|
78
|
+
let(:serialized_event) { described_class.serialize(event) }
|
79
|
+
before { expect(Jouba.Store).to receive(:set).with(event.key, serialized_event) }
|
80
|
+
|
81
|
+
it 'persist an event in the store' do
|
82
|
+
event.track
|
19
83
|
end
|
20
84
|
end
|
21
85
|
end
|