event_sourced_record 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (37) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +25 -0
  3. data/Appraisals +8 -0
  4. data/Gemfile +4 -0
  5. data/LICENSE.txt +22 -0
  6. data/README.md +131 -0
  7. data/Rakefile +10 -0
  8. data/event_sourced_record.gemspec +37 -0
  9. data/lib/event_sourced_record/calculator.rb +101 -0
  10. data/lib/event_sourced_record/event/event_type_config.rb +30 -0
  11. data/lib/event_sourced_record/event.rb +94 -0
  12. data/lib/event_sourced_record/version.rb +3 -0
  13. data/lib/event_sourced_record.rb +7 -0
  14. data/lib/generators/event_sourced_record/USAGE +8 -0
  15. data/lib/generators/event_sourced_record/calculator_generator.rb +19 -0
  16. data/lib/generators/event_sourced_record/event_generator.rb +40 -0
  17. data/lib/generators/event_sourced_record/event_sourced_record_generator.rb +41 -0
  18. data/lib/generators/event_sourced_record/observer_generator.rb +34 -0
  19. data/lib/generators/event_sourced_record/projection_generator.rb +50 -0
  20. data/lib/generators/event_sourced_record/templates/calculator.rb +7 -0
  21. data/lib/generators/event_sourced_record/templates/event_model.rb +13 -0
  22. data/lib/generators/event_sourced_record/templates/observer.rb +8 -0
  23. data/lib/generators/event_sourced_record/templates/projection_model.rb +15 -0
  24. data/lib/generators/rspec/service_generator.rb +15 -0
  25. data/lib/generators/rspec/templates/service_spec.rb +6 -0
  26. data/lib/generators/test_unit/service_generator.rb +15 -0
  27. data/lib/generators/test_unit/templates/service_test.rb +9 -0
  28. data/test/event_sourced_record/calculator_test.rb +111 -0
  29. data/test/event_sourced_record/event_test.rb +112 -0
  30. data/test/generators/calculator_generator_test.rb +25 -0
  31. data/test/generators/event_generator_test.rb +35 -0
  32. data/test/generators/event_sourced_record_generator_test.rb +38 -0
  33. data/test/generators/observer_generator_test.rb +19 -0
  34. data/test/generators/projection_generator_test.rb +36 -0
  35. data/test/generators/templates/application.rb +12 -0
  36. data/test/test_helper.rb +82 -0
  37. metadata +217 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 092ed18da567347e5ac8f364e1d0f462f53e017d
4
+ data.tar.gz: ee8bd1282829f2b7f4f0f67a52415ef5d2d2b433
5
+ SHA512:
6
+ metadata.gz: 5453d5b029fc488d6ac88d910b5c200bc9fe435b30616256c1058aa7dc686024c263ec8f8b66bf3db3427031d2d105d288760bbad91005efdc32096429ac1a68
7
+ data.tar.gz: c8a38090c8720b7fd0d5476ccd56dec501bebe5503077eafb63da0a7c91834a5af9d4d7911d73f10228619cff5c531f43ab8f9ff00f9efb24ad060ef8fdbd8e7
data/.gitignore ADDED
@@ -0,0 +1,25 @@
1
+ *.gem
2
+ *.rbc
3
+ .bundle
4
+ .config
5
+ .yardoc
6
+ Gemfile.lock
7
+ InstalledFiles
8
+ _yardoc
9
+ coverage
10
+ doc/
11
+ lib/bundler/man
12
+ pkg
13
+ rdoc
14
+ spec/reports
15
+ test/tmp
16
+ test/version_tmp
17
+ tmp
18
+ *.bundle
19
+ *.so
20
+ *.o
21
+ *.a
22
+ mkmf.log
23
+ .ruby-version
24
+ gemfiles/*.gemfile
25
+ gemfiles/*.gemfile.lock
data/Appraisals ADDED
@@ -0,0 +1,8 @@
1
+ appraise "rails-4-0" do
2
+ gem "rails", "4.0.13"
3
+ end
4
+
5
+ appraise "rails-4-2" do
6
+ gem "rails", "4.2.0"
7
+ end
8
+
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in event_sourced_record.gemspec
4
+ gemspec
data/LICENSE.txt ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2014 Francis Hwang
2
+
3
+ MIT License
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,131 @@
1
+ # Event Sourced Record
2
+
3
+ Event Sourced Record offers an idiomatic way to use the Event Sourcing pattern in Rails code.
4
+
5
+ With Event Sourcing, every change to the state of an object is recorded as an immutable event in a replayable sequence. The result is decoupled code that simplifies state debugging and retrospective reporting.
6
+
7
+ For more, see Martin Fowler's writeup of the pattern: http://martinfowler.com/eaaDev/EventSourcing.html
8
+
9
+ ## Installation
10
+
11
+ Add this line to your application's Gemfile:
12
+
13
+ gem 'event_sourced_record'
14
+
15
+ And then execute:
16
+
17
+ $ bundle
18
+
19
+ Or install it yourself as:
20
+
21
+ $ gem install event_sourced_record
22
+
23
+ Event Sourced Record uses observers, so you'll need to add them to your Gemfile:
24
+
25
+ gem 'rails-observers'
26
+
27
+ Note that only Rails 4 is supported as of this writing. Rails 3 support is coming soon.
28
+
29
+ ## Usage
30
+
31
+ Generate the required classes with `rails generate event_sourced_record`:
32
+
33
+ rails generate event_sourced_record Subscription \
34
+ user_id:integer bottles_per_shipment:integer \
35
+ bottles_left:integer
36
+
37
+ The argument list is the same as with `rails generate model`. This will generate the event model, the projection, the calculator, and the observer.
38
+
39
+ ### Event model
40
+
41
+ The event model is an ActiveRecord model but it can act significantly different based on what type of event it is. Use `event_type` to configure these types:
42
+
43
+ class SubscriptionEvent < ActiveRecord::Base
44
+ include EventSourcedRecord::Event
45
+
46
+ event_type :creation do
47
+ attributes :bottles_per_shipment, :bottles_purchased, :user_id
48
+
49
+ validates :bottles_per_shipment, presence: true, numericality: true
50
+ validates :bottles_purchased, presence: true, numericality: true
51
+ validates :user_id, presence: true
52
+ end
53
+
54
+ event_type :change_settings do
55
+ attributes :bottles_per_shipment
56
+
57
+ validates :bottles_per_shipment, numericality: true
58
+ end
59
+ end
60
+
61
+ The easiest way to create these records is with the scopes that are automatically generated by `event_type`:
62
+
63
+ SubscriptionEvent.creation.create!(
64
+ bottles_per_shipment: 1, bottles_purchased: 6, user_id: current_user.id
65
+ )
66
+
67
+ ## Projection
68
+
69
+ The projection is the ActiveRecord model that is generated deterministically with the data in the timestamped events combined with the logic in the calculator. Projections shouldn't have any code for modifying themselves, as that will be done externally. Accordingly, projections end up being fairly small classes:
70
+
71
+ class Subscription < ActiveRecord::Base
72
+ has_many :subscription_events
73
+
74
+ validates :uuid, uniqueness: true
75
+ end
76
+
77
+ (`uuid` is core to Event Sourced Model, so please don't remove its validations.)
78
+
79
+ ## Calculator
80
+
81
+ The calculator is a service class that idempotently calculates the state of the projection by running one method for each event, in order. Name the methods `advance_[event_type]`.
82
+
83
+ class SubscriptionCalculator < EventSourcedRecord::Calculator
84
+ events :subscription_events
85
+
86
+ def advance_creation(event)
87
+ @subscription.user_id = event.user_id
88
+ @subscription.bottles_per_shipment = event.bottles_per_shipment
89
+ @subscription.bottles_left = event.bottles_purchased
90
+ end
91
+
92
+ def advance_change_settings(event)
93
+ @subscription.bottles_per_shipment = event.bottles_per_shipment
94
+ end
95
+ end
96
+
97
+ Calculators can also include other associated models, which can come in handy (as long as those models don't change significantly after creation). Add that class to `events` and name the advance method `advance_[underscored class name]`:
98
+
99
+ class SubscriptionCalculator < EventSourcedRecord::Calculator
100
+ events :subscription_events, :shipments
101
+
102
+ def advance_shipment(shipment)
103
+ @subscription.bottles_left -= shipment.num_bottles
104
+ end
105
+ end
106
+
107
+ ## Observer
108
+
109
+ The observer simply tracks creations of events related to the projection and runs the calculator every time. You may never have to modify what the generator creates for you:
110
+
111
+ class SubscriptionEventObserver < ActiveRecord::Observer
112
+ observe :subscription_event
113
+
114
+ def after_create(event)
115
+ SubscriptionCalculator.new(event).run.save!
116
+ end
117
+ end
118
+
119
+ If you use other models as events, simply add them to the `observe` method:
120
+
121
+ class SubscriptionEventObserver < ActiveRecord::Observer
122
+ observe :subscription_event, :shipment
123
+
124
+ ## Contributing
125
+
126
+ 1. Fork it ( https://github.com/[my-github-username]/event_sourced_record/fork )
127
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
128
+ 3. Commit your changes (`git commit -am 'Add some feature'`)
129
+ 4. Push to the branch (`git push origin my-new-feature`)
130
+ 5. Create a new Pull Request
131
+
data/Rakefile ADDED
@@ -0,0 +1,10 @@
1
+ require "bundler/gem_tasks"
2
+ require 'rake/testtask'
3
+
4
+ Rake::TestTask.new do |t|
5
+ t.libs << "test"
6
+ t.test_files = FileList['test/**/*_test.rb']
7
+ t.verbose = true
8
+ end
9
+
10
+ task default: [:test]
@@ -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 'event_sourced_record/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "event_sourced_record"
8
+ spec.version = EventSourcedRecord::VERSION
9
+ spec.authors = ["Francis Hwang"]
10
+ spec.email = ["sera@fhwang.net"]
11
+ spec.summary = %q{Event Sourcing with ActiveRecord.}
12
+ spec.description = %q{Event Sourcing with ActiveRecord.}
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.post_install_message = <<-MESSAGE
22
+ Thanks for installing!
23
+
24
+ EventSourcedRecord uses Rails observers. If you are using Rails 4.0 or greater, add `rails-observers` to your Gemfile.
25
+ MESSAGE
26
+
27
+ spec.add_dependency 'activemodel'
28
+
29
+ spec.add_development_dependency "bundler", "~> 1.6"
30
+ spec.add_development_dependency "rake"
31
+ spec.add_development_dependency 'activerecord'
32
+ spec.add_development_dependency 'appraisal'
33
+ spec.add_development_dependency 'mocha'
34
+ spec.add_development_dependency 'railties'
35
+ spec.add_development_dependency 'sqlite3'
36
+ spec.add_development_dependency 'pry'
37
+ end
@@ -0,0 +1,101 @@
1
+ class EventSourcedRecord::Calculator
2
+ def self.events(*event_symbols)
3
+ @@event_symbols = event_symbols
4
+ end
5
+
6
+ def self.event_classes
7
+ @@event_symbols.map { |sym|
8
+ Module.const_get(sym.to_s.singularize.camelize)
9
+ }
10
+ end
11
+
12
+ def initialize(lookup)
13
+ @lookup = lookup
14
+ end
15
+
16
+ def run(options = {})
17
+ instance_variable_set(projection_variable_name, find_or_build_record)
18
+ sorted_events(options[:last_event_time]).each do |event|
19
+ advance_event(event)
20
+ end
21
+ instance_variable_get projection_variable_name
22
+ end
23
+
24
+ private
25
+
26
+ def advance_event(event)
27
+ if event.respond_to?(:event_type)
28
+ method = "advance_#{event.event_type}"
29
+ else
30
+ method = "advance_#{event.class.name.underscore}"
31
+ end
32
+ self.send(method, event)
33
+ end
34
+
35
+ def event_class
36
+ Module.const_get(projection_class_name + 'Event')
37
+ end
38
+
39
+ def find_or_build_record
40
+ case @lookup
41
+ when Integer
42
+ projection_class.where(id: @lookup).first
43
+ when projection_class
44
+ projection_class.where(id: @lookup.id).first
45
+ when *self.class.event_classes
46
+ find_or_build_record_by_event_instance(@lookup)
47
+ else
48
+ find_or_build_record_by_uuid(@lookup)
49
+ end
50
+ end
51
+
52
+ def find_or_build_record_by_event_instance(event)
53
+ if event.respond_to?(uuid_field)
54
+ find_or_build_record_by_uuid(event.send(uuid_field))
55
+ else
56
+ conditions = {id: event.send(projection_name + '_id')}
57
+ projection_class.where(conditions).first
58
+ end
59
+ end
60
+
61
+ def find_or_build_record_by_uuid(uuid)
62
+ projection_class.where(uuid: uuid).first || projection_class.new(uuid: uuid)
63
+ end
64
+
65
+ def projection_class
66
+ Module.const_get(projection_class_name)
67
+ end
68
+
69
+ def projection_class_name
70
+ self.class.name.gsub(/Calculator$/, '')
71
+ end
72
+
73
+ def projection_name
74
+ projection_class_name.underscore
75
+ end
76
+
77
+ def projection_variable_name
78
+ "@#{projection_name}".to_sym
79
+ end
80
+
81
+ def sorted_events(last_event_time)
82
+ projection = instance_variable_get projection_variable_name
83
+ self.class.event_classes.map { |event_class|
84
+ conditions = nil
85
+ if event_class.column_names.include?(uuid_field)
86
+ conditions = {uuid_field => projection.uuid}
87
+ else
88
+ conditions = {projection_name + '_id' => projection.id} if projection
89
+ end
90
+ conditions ? event_class.where(conditions).to_a : []
91
+ }.flatten.sort_by(&:created_at).reject { |evt|
92
+ if last_event_time
93
+ evt.created_at > last_event_time
94
+ end
95
+ }
96
+ end
97
+
98
+ def uuid_field
99
+ projection_name + '_uuid'
100
+ end
101
+ end
@@ -0,0 +1,30 @@
1
+ require 'active_model'
2
+
3
+ class EventSourcedRecord::Event::EventTypeConfig
4
+ include ::ActiveModel::Validations::ClassMethods
5
+
6
+ attr_reader :_validators
7
+
8
+ def initialize
9
+ @_validators = Hash.new { |h,k| h[k] = [] }
10
+ end
11
+
12
+ def attributes(*attrs)
13
+ attrs.present? ? @attributes = attrs : @attributes
14
+ end
15
+
16
+ def const_get(sym_or_str, inherit=true)
17
+ ActiveModel::Validations.const_get(sym_or_str, inherit)
18
+ end
19
+
20
+ # Don't do anything; the interesting work has already been done in
21
+ # `validate_with` adding validators to @_validators
22
+ def validate(*args, &block)
23
+ end
24
+
25
+ def validate_record(record)
26
+ _validators.values.flatten.each do |validator|
27
+ validator.validate(record)
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,94 @@
1
+ module EventSourcedRecord::Event
2
+ def self.included(model)
3
+ model.cattr_accessor :_event_type_configs
4
+ model.extend ClassMethods
5
+ model.after_initialize :ensure_data
6
+ model.after_initialize :ensure_projection_uuid
7
+ model.after_initialize :lock_event_type
8
+ model.validates :event_type, presence: true
9
+ model.validate :validate_corrent_event_type
10
+ model.validate :validate_by_event_type
11
+ model.serialize :data
12
+ end
13
+
14
+ def event_type
15
+ attributes["event_type"]
16
+ end
17
+
18
+ def event_type=(value)
19
+ if @event_type_locked
20
+ raise EventTypeImmutableError, "Event types can't be changed"
21
+ else
22
+ write_attribute(:event_type, value)
23
+ end
24
+ end
25
+
26
+ private
27
+
28
+ def ensure_data
29
+ self.data ||= {}
30
+ end
31
+
32
+ def ensure_projection_uuid
33
+ unless self.send(projection_uuid_name)
34
+ self.send("#{projection_uuid_name}=", SecureRandom.uuid)
35
+ end
36
+ end
37
+
38
+ def event_type_config
39
+ self.class.event_type_config(event_type)
40
+ end
41
+
42
+ def lock_event_type
43
+ @event_type_locked = true
44
+ end
45
+
46
+ def method_missing(meth, *args, &block)
47
+ if event_type_config && event_type_config.attributes.include?(meth)
48
+ ensure_data
49
+ self.data[meth.to_s]
50
+ elsif event_type_config && event_type_config.attributes.any? { |a| "#{a}=".to_sym == meth }
51
+ ensure_data
52
+ attr = meth.to_s.gsub(/=$/, '')
53
+ self.data[attr] = args.first
54
+ else
55
+ super
56
+ end
57
+ end
58
+
59
+ def projection_uuid_name
60
+ self.class.name.underscore.gsub(/_event$/, '') + '_uuid'
61
+ end
62
+
63
+ def validate_corrent_event_type
64
+ unless self.class.event_types.include?(event_type.to_s)
65
+ errors.add(:event_type, "is not a valid event type")
66
+ end
67
+ end
68
+
69
+ def validate_by_event_type
70
+ event_type_config.validate_record(self) if event_type_config
71
+ end
72
+
73
+ module ClassMethods
74
+ def event_type(event_type, &block)
75
+ scope event_type, -> { where(event_type: event_type) }
76
+ self._event_type_configs ||= HashWithIndifferentAccess.new
77
+ config = EventTypeConfig.new
78
+ self._event_type_configs[event_type] = config
79
+ config.instance_eval(&block)
80
+ end
81
+
82
+ def event_type_config(event_type)
83
+ self._event_type_configs[event_type]
84
+ end
85
+
86
+ def event_types
87
+ self._event_type_configs.keys
88
+ end
89
+ end
90
+
91
+ class EventTypeImmutableError < StandardError; end
92
+ end
93
+
94
+ require 'event_sourced_record/event/event_type_config'
@@ -0,0 +1,3 @@
1
+ module EventSourcedRecord
2
+ VERSION = "0.0.1"
3
+ end
@@ -0,0 +1,7 @@
1
+ module EventSourcedRecord
2
+ # Your code goes here...
3
+ end
4
+
5
+ require "event_sourced_record/calculator"
6
+ require "event_sourced_record/event"
7
+ require "event_sourced_record/version"
@@ -0,0 +1,8 @@
1
+ Description:
2
+ Explain the generator
3
+
4
+ Example:
5
+ rails generate event_sourced_record Thing
6
+
7
+ This will create:
8
+ what/will/it/create
@@ -0,0 +1,19 @@
1
+ class EventSourcedRecord::CalculatorGenerator < Rails::Generators::NamedBase
2
+ source_root File.expand_path('../templates', __FILE__)
3
+ argument :attributes,
4
+ :type => :array, :default => []
5
+
6
+ def create_calculator_file
7
+ template(
8
+ 'calculator.rb', File.join('app/services', class_path, "#{file_name}.rb")
9
+ )
10
+ end
11
+
12
+ hook_for :test_framework, as: :service
13
+
14
+ private
15
+
16
+ def event_name
17
+ file_name.gsub(/_calculator$/, '') + '_event'
18
+ end
19
+ end
@@ -0,0 +1,40 @@
1
+ class EventSourcedRecord::EventGenerator < Rails::Generators::NamedBase
2
+ source_root File.expand_path('../templates', __FILE__)
3
+ argument :attributes,
4
+ :type => :array, :default => []
5
+
6
+ def create_migration_file
7
+ attributes_str = attributes.map { |attr|
8
+ attr_banner = attr.name
9
+ attr_banner << ":#{attr.type}" if attr.type
10
+ attr_banner << ':index' if attr.has_index?
11
+ attr_banner
12
+ }.join(' ')
13
+ generate(
14
+ "migration", "create_#{event_table_name} #{attributes_str}"
15
+ )
16
+ end
17
+
18
+ def create_model_file
19
+ template(
20
+ 'event_model.rb',
21
+ File.join('app/models', class_path, "#{event_file_name}.rb")
22
+ )
23
+ end
24
+
25
+ hook_for :test_framework, as: :model
26
+
27
+ private
28
+
29
+ def event_class_name
30
+ class_name
31
+ end
32
+
33
+ def event_file_name
34
+ file_name
35
+ end
36
+
37
+ def event_table_name
38
+ table_name
39
+ end
40
+ end
@@ -0,0 +1,41 @@
1
+ class EventSourcedRecord::EventSourcedRecordGenerator < Rails::Generators::NamedBase
2
+ source_root File.expand_path('../templates', __FILE__)
3
+ argument :attributes,
4
+ :type => :array, :default => [],
5
+ :banner => "field[:type][:index] field[:type][:index]"
6
+
7
+ check_class_collision
8
+
9
+ def create_calculator_class
10
+ generate "event_sourced_record:calculator", "#{file_name}_calculator"
11
+ end
12
+
13
+ def create_event
14
+ arguments = [
15
+ "#{file_name}_uuid:string:index", "event_type:string",
16
+ "data:text", "created_at:datetime"
17
+ ].join(' ')
18
+ generate "event_sourced_record:event", "#{file_name}_event #{arguments}"
19
+ end
20
+
21
+ def create_observer
22
+ generate "event_sourced_record:observer", "#{file_name}_event_observer"
23
+ end
24
+
25
+ def create_projection
26
+ attr_strings = attributes.map { |attr|
27
+ attr_string = attr.name
28
+ attr_string << ":#{attr.type}" if attr.type
29
+ attr_string << ':index' if attr.has_index?
30
+ attr_string
31
+ }
32
+ projection_attributes = attr_strings.join(' ')
33
+ generate "event_sourced_record:projection", "#{file_name} #{projection_attributes}"
34
+ end
35
+
36
+ protected
37
+
38
+ def calculator_class_name
39
+ class_name + 'Calculator'
40
+ end
41
+ end
@@ -0,0 +1,34 @@
1
+ class EventSourcedRecord::ObserverGenerator < Rails::Generators::NamedBase
2
+ source_root File.expand_path('../templates', __FILE__)
3
+
4
+ def create_observer_file
5
+ template(
6
+ 'observer.rb',
7
+ File.join('app/observers', class_path, "#{file_name}.rb")
8
+ )
9
+ end
10
+
11
+ def create_application_observer_hook
12
+ application do
13
+ "config.active_record.observers = :#{file_name}"
14
+ end
15
+ end
16
+
17
+ private
18
+
19
+ def calculator_class_name
20
+ (projection_name + '_calculator').camelize
21
+ end
22
+
23
+ def event_name
24
+ projection_name + '_event'
25
+ end
26
+
27
+ def event_uuid_field
28
+ projection_name + '_uuid'
29
+ end
30
+
31
+ def projection_name
32
+ file_name.gsub(/_event_observer$/, '')
33
+ end
34
+ end
@@ -0,0 +1,50 @@
1
+ class EventSourcedRecord::ProjectionGenerator < Rails::Generators::NamedBase
2
+ source_root File.expand_path('../templates', __FILE__)
3
+ argument :attributes,
4
+ :type => :array, :default => []
5
+
6
+ def create_migration_file
7
+ generate(
8
+ "migration", "create_#{projection_table_name} #{migration_attributes}"
9
+ )
10
+ end
11
+
12
+ def create_model_file
13
+ template(
14
+ 'projection_model.rb',
15
+ File.join('app/models', class_path, "#{projection_file_name}.rb")
16
+ )
17
+ end
18
+
19
+ hook_for :test_framework, as: :model
20
+
21
+ private
22
+
23
+ def migration_attributes
24
+ attr_strings = attributes.map { |attr|
25
+ attr_string = attr.name
26
+ attr_string << ":#{attr.type}" if attr.type
27
+ attr_string << ':index' if attr.has_index?
28
+ attr_string
29
+ }
30
+ attr_strings << "uuid:string:uniq"
31
+ attr_strings.join(' ')
32
+ end
33
+
34
+
35
+ def projection_class_name
36
+ class_name
37
+ end
38
+
39
+ def projection_file_name
40
+ file_name
41
+ end
42
+
43
+ def projection_parent_class_name
44
+ "ActiveRecord::Base"
45
+ end
46
+
47
+ def projection_table_name
48
+ table_name
49
+ end
50
+ end