fourth_dimensional 0.1.0 → 0.1.1

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 11c3860996942f56fa43873961fdbbf82069813122122326a29d286d33455990
4
- data.tar.gz: 42f4c00c835316f5e0fe04a42e699afb25ef758c5e4d6ed3a87242c9eccd2567
3
+ metadata.gz: 9e42f145173551ac50193f92d2dea18e824e9c08807040cb53bdab953ff57061
4
+ data.tar.gz: a8813fea8c342ca410cb5935dd4e4deece9fb247fa110b3b03f2f9044ad266bf
5
5
  SHA512:
6
- metadata.gz: 55df185870d5e5f68b6cf6bd68c83b69e54ec1e3bc606e5568574387a95a441f7c4b75245f9f29e53d3d2916fb18a0b8d34b330a40e7581b80287cc22f9315c9
7
- data.tar.gz: c7a17815b197d2a9767714a002b148e54c2fac6fdb7d3f4094ccd132308899d7e96dfd6f8fcb856eb0397ab6db00686cdba009c20b4d7e121a28c2450a9496f3
6
+ metadata.gz: 32b5cec8272c1d40448a60ffaad530713f02d5373e2a9f181ebacb39a92cf378300ba51a80acfc138fca0fa5f5443612177a2a2fe0f3bb6b7caeaf7fcde3d510
7
+ data.tar.gz: 39c610fad07ed26675a66c30f5c3324837f70fea7301a5d78bf3b4df1a5aaf68fd4307760538085b7ea32d0e78470bdcbf3e7c0e3be1adafc110a3afb55bfdb5
@@ -1,7 +1,80 @@
1
1
  require "fourth_dimensional/version"
2
+ require "fourth_dimensional/railtie" if defined?(Rails)
2
3
 
3
4
  module FourthDimensional
4
5
  class Error < StandardError; end
5
6
 
7
+ autoload :AggregateRoot, 'fourth_dimensional/aggregate_root'
8
+ autoload :Command, 'fourth_dimensional/command'
9
+ autoload :CommandHandler, 'fourth_dimensional/command_handler'
10
+ autoload :Configuration, 'fourth_dimensional/configuration'
6
11
  autoload :Event, 'fourth_dimensional/event'
12
+ autoload :Eventable, 'fourth_dimensional/eventable'
13
+ autoload :EventLoaders, 'fourth_dimensional/event_loaders'
14
+ autoload :RecordProjector, 'fourth_dimensional/record_projector'
15
+ autoload :Repository, 'fourth_dimensional/repository'
16
+
17
+ # The singleton instance of Configuration
18
+ def self.config
19
+ @configuration ||= Configuration.new
20
+ end
21
+
22
+ # Yields the Configuration instance
23
+ #
24
+ # FourthDimensional.configure do |config|
25
+ # config.command_handlers = [
26
+ # CommentCommandHandler,
27
+ # PostCommandHandler
28
+ # ]
29
+ # end
30
+ def self.configure
31
+ yield config
32
+ end
33
+
34
+ # Iniitlaizes a Repository with the required dependencies.
35
+ #
36
+ # FourthDimensional.repository # => FourthDimensional::Repository
37
+ def self.build_repository
38
+ Repository.new(event_loader: config.event_loader)
39
+ end
40
+
41
+ # Runs a single or array of commands through all command handlers, saves
42
+ # commands and applied events, and invokes event handlers.
43
+ #
44
+ # Fourthdimensional.execute_command(command)
45
+ # FourthDimensional.execute_commands(command1, command2)
46
+ # FourthDimensional.execute_commands([command1, command2])
47
+ def self.execute_commands(*commands)
48
+ repository = build_repository
49
+ call_command_handlers(repository, commands)
50
+ saved_events = save_commands_and_events(repository)
51
+ call_event_handlers(saved_events)
52
+ end
53
+
54
+ class << self
55
+ alias_method :execute_command, :execute_commands
56
+ end
57
+
58
+ private
59
+
60
+ def self.call_command_handlers(repository, commands)
61
+ config.command_handlers.each do |command_handler|
62
+ commands.flatten.each do |command|
63
+ command_handler.new(repository: repository).call(command)
64
+ end
65
+ end
66
+ end
67
+
68
+ def self.save_commands_and_events(repository)
69
+ config.event_loader.save_commands_and_events(
70
+ commands: repository.called_commands,
71
+ events: repository.applied_events
72
+ )
73
+ end
74
+
75
+ def self.call_event_handlers(events)
76
+ config.event_handlers.each do |event_handler|
77
+ event_handler.call(events)
78
+ end
79
+ end
7
80
  end
@@ -0,0 +1,100 @@
1
+ module FourthDimensional
2
+ # == FourthDimensional::AggregateRoot
3
+ #
4
+ # An aggregate root is an object whose entire state is built by applying
5
+ # events in sequential order.
6
+ #
7
+ # Often you will see an aggregate root providing a method to create the event
8
+ # followed by a event binding to apply the changes to the current object.
9
+ #
10
+ # class Post < FourthDimensional::AggregateRoot
11
+ # attr_reader :state, :title
12
+ #
13
+ # def initialize(*args)
14
+ # super
15
+ #
16
+ # @state = :draft
17
+ # end
18
+ #
19
+ # def add(title:)
20
+ # apply PostAdded, data: { title: title }
21
+ # end
22
+ #
23
+ # def delete
24
+ # apply PostDeleted
25
+ # end
26
+ #
27
+ # on PostAdded do |event|
28
+ # @state = :added
29
+ # @title = event.data.fetch('title')
30
+ # end
31
+ #
32
+ # on PostDeleted do |event|
33
+ # @state = :deleted
34
+ # end
35
+ # end
36
+ #
37
+ # aggregate = Post.new(id: SecureRandom.uuid)
38
+ # aggregate.state # => :draft
39
+ # aggregate.title # => nil
40
+ #
41
+ # aggregate.add(title: 'post-title')
42
+ # aggregate.state # => :added
43
+ # aggregate.title # => 'post-title'
44
+ #
45
+ # aggregate.delete
46
+ # aggregate.state # => :deleted
47
+ class AggregateRoot
48
+ include Eventable
49
+
50
+ UnknownEventError = Class.new(Error)
51
+
52
+ # aggregate id
53
+ attr_reader :id
54
+
55
+ # array of events applied
56
+ attr_reader :applied_events
57
+
58
+ # current version
59
+ attr_reader :version
60
+
61
+ # Initializes an aggregate with an id
62
+ def initialize(id:)
63
+ @id = id
64
+
65
+ @applied_events = []
66
+ @version = 1
67
+ end
68
+
69
+ # Applies an event to the aggregate when a callback is bound. **+args+ are
70
+ # merged with the +id+ of the aggregate.
71
+ #
72
+ # Callbacks are invoked within the instance of the aggregate root.
73
+ def apply(event_class, **args)
74
+ event = event_class.new(args.merge(aggregate_id: id))
75
+ apply_existing_event(event)
76
+ applied_events << event
77
+ end
78
+
79
+ # Calls the event binding without persisting the event being applied. Used
80
+ # when loading an aggregate from an existing store.
81
+ #
82
+ # post.apply_existing_event(title_updated_event)
83
+ def apply_existing_event(event)
84
+ callback = self.class.event_bindings[event.class]
85
+
86
+ if callback.nil?
87
+ raise UnknownEventError.new("#{self.class.name} doesn't have a binding for '#{event.class}'")
88
+ end
89
+
90
+ instance_exec(event, &callback)
91
+ @version = next_version
92
+ end
93
+
94
+ private
95
+
96
+ def next_version
97
+ version + 1
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,63 @@
1
+ require "active_model"
2
+
3
+ module FourthDimensional
4
+ # == FourthDimensional::Command
5
+ #
6
+ # Commands are the input to create events. They provide an early validation
7
+ # step to ensuring the data format is correct before validating the current
8
+ # state of the system.
9
+ #
10
+ # class AddPost < FourthDimensional::Command
11
+ # attributes :title, :body, :published
12
+ # validates_presence_of :title, :body
13
+ # end
14
+ #
15
+ # AddPost.new # => raises ArgumentError.new("missing keywords: aggregate_id, :title, :body, :published)
16
+ # command = AddPost.new(aggregate_id: '1-2-3', title: 'post-title', body: 'post-body', published: false)
17
+ # command.valid? # => true
18
+ # command.aggregate_id # => '1-2-3'
19
+ # command.title # => 'post-title'
20
+ # command.body # => 'post-body'
21
+ # command.published # => false
22
+ #
23
+ # command.to_h # => {'title' => 'post-title', 'body' => 'post-body', 'published' => false}
24
+ class Command
25
+ include ActiveModel::Validations
26
+
27
+ # The aggregate the command is acting on
28
+ attr_reader :aggregate_id
29
+
30
+ def initialize(aggregate_id:)
31
+ @aggregate_id = aggregate_id
32
+ end
33
+
34
+ def to_h
35
+ {}
36
+ end
37
+
38
+ # Defines an initializer with required keyword arguments, readonly only
39
+ # attributes and +to_h+ to access all defined attributes
40
+ #
41
+ # class AddPost < FourthDimensional::Command
42
+ # attributes :title, :body, :published
43
+ # end
44
+ def self.attributes(*attributes)
45
+ attr_reader *attributes
46
+
47
+ method_arguments = attributes.map { |arg| "#{arg}:" }.join(', ')
48
+ method_assignments = attributes.map { |arg| "@#{arg} = #{arg}" }.join(';')
49
+ method_attrs_to_hash = attributes.map { |arg| "'#{arg}' => #{arg}" }.join(',')
50
+
51
+ class_eval <<~CODE
52
+ def initialize(aggregate_id:, #{method_arguments})
53
+ @aggregate_id = aggregate_id
54
+ #{method_assignments}
55
+ end
56
+
57
+ def to_h
58
+ {#{method_attrs_to_hash}}
59
+ end
60
+ CODE
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,79 @@
1
+ module FourthDimensional
2
+ # == FourthDimensional::CommandHandler
3
+ #
4
+ # Command handlers have bindings that wrap a Command to load an aggregate and
5
+ # apply events.
6
+ #
7
+ # class PostCommandHandler < FourthDimensional::CommandHandler
8
+ # on AddPost do |command|
9
+ # with_aggregate(PostAggregate, command) do |post|
10
+ # post.add(title: command.title, body: command.body)
11
+ # end
12
+ # end
13
+ #
14
+ # # manually load and save aggregate
15
+ # on UpdateTitle do |command|
16
+ # post = repository.load_aggregate(PostAggregate, command.aggregate_id)
17
+ # post.update_title(title: command.title)
18
+ # save(command, post)
19
+ # end
20
+ #
21
+ # on PublishPost do |command|
22
+ # with_aggregate(PostAggregate, command) do |post|
23
+ # post.publish
24
+ # end
25
+ # end
26
+ # end
27
+ class CommandHandler
28
+ include Eventable
29
+
30
+ attr_reader :repository
31
+
32
+ class CommandAndEvents
33
+ attr_reader :command, :events
34
+
35
+ def initialize(command:, events:)
36
+ @command = command
37
+ @events = events
38
+ end
39
+
40
+ def ==(other)
41
+ self.class == other.class && command == other.command && events == other.events
42
+ end
43
+ end
44
+
45
+ def initialize(repository:)
46
+ @repository = repository
47
+ end
48
+
49
+ # Invokes a callback for an command.
50
+ def call(command)
51
+ callback = self.class.event_bindings[command.class]
52
+ return if callback.nil?
53
+ instance_exec(command, &callback)
54
+ end
55
+
56
+ # Yields the aggregate and saves the applied events
57
+ def with_aggregate(aggregate_class, command, &block)
58
+ aggregate = repository.load_aggregate(aggregate_class, command.aggregate_id)
59
+ yield aggregate
60
+ save(command, aggregate)
61
+ end
62
+
63
+ # Saves the command and aggregate's applied events
64
+ #
65
+ # class PostCommandHandler < FourthDimensional::CommandHandler
66
+ # on AddPost do |command|
67
+ # post = repository.load_aggregate(PostAggregate, command.aggregate_id)
68
+ # post.add(title: command.title)
69
+ # save(command, post)
70
+ # end
71
+ # end
72
+ def save(command, aggregate)
73
+ repository.save_command_and_events(CommandAndEvents.new(
74
+ command: command,
75
+ events: aggregate.applied_events
76
+ ))
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,28 @@
1
+ module FourthDimensional
2
+ # == FourthDimensional::Configuration
3
+ #
4
+ # FourthDimensional.configure do |config|
5
+ # config.command_handlers = [...]
6
+ # config.event_handlers = [...]
7
+ # config.event_loader = FourthDimensional::ActiveRecord::EventLoader.new
8
+ # end
9
+ class Configuration
10
+ # An array of command handlers
11
+ attr_accessor :command_handlers
12
+
13
+ # An array of event handlers
14
+ attr_accessor :event_handlers
15
+
16
+ # The event loader
17
+ attr_accessor :event_loader
18
+
19
+ # The table prefix
20
+ attr_accessor :table_prefix
21
+
22
+ def initialize
23
+ @command_handlers = []
24
+ @event_handlers = []
25
+ @table_prefix = 'fourd_'
26
+ end
27
+ end
28
+ end
@@ -1,3 +1,6 @@
1
+ require "active_support/core_ext/hash/keys"
2
+ require "active_support/inflector"
3
+
1
4
  module FourthDimensional
2
5
  # == FourthDimensional::Event
3
6
  #
@@ -29,6 +32,9 @@ module FourthDimensional
29
32
  # hash of data with stringified keys
30
33
  attr_reader :data, :metadata
31
34
 
35
+ # persisted event attributes
36
+ attr_reader :id, :version, :created_at, :updated_at
37
+
32
38
  # Initializes an event with the required +aggregate_id+ and optional +data+
33
39
  # and +metadata+.
34
40
  #
@@ -41,14 +47,33 @@ module FourthDimensional
41
47
  # strings to accommodate deserializing the values from json.
42
48
  #
43
49
  # event = MyEvent.new(aggregate_id: '1-2-3',
50
+ # version: 1,
44
51
  # data: { one: 1 },
45
52
  # metadata: { two: 2 })
53
+ # event.version # => 1
46
54
  # event.data # => { 'one' => 1 }
47
55
  # event.metadata # => { 'two' => 2 }
48
- def initialize(aggregate_id:, data: nil, metadata: nil)
56
+ def initialize(aggregate_id:,
57
+ id: nil,
58
+ version: nil,
59
+ created_at: nil,
60
+ updated_at: nil,
61
+ data: nil,
62
+ metadata: nil)
49
63
  @aggregate_id = aggregate_id
50
- @data = data || {}
51
- @metadata = metadata || {}
64
+ @id = id
65
+ @version = version
66
+ @created_at = created_at
67
+ @updated_at = updated_at
68
+ @data = (data || {}).transform_keys(&:to_s)
69
+ @metadata = (metadata || {}).transform_keys(&:to_s)
70
+ end
71
+
72
+ # Underscored event type
73
+ #
74
+ # Products::Events::Created.new.type # => "products/events/created"
75
+ def type
76
+ self.class.name.underscore
52
77
  end
53
78
  end
54
79
  end
@@ -0,0 +1,5 @@
1
+ module FourthDimensional
2
+ module EventLoaders
3
+ autoload :ActiveRecord, 'fourth_dimensional/event_loaders/active_record'
4
+ end
5
+ end
@@ -0,0 +1,85 @@
1
+ require 'active_record'
2
+
3
+ module FourthDimensional
4
+ module EventLoaders
5
+ # == FourthDimensional::EventLoaders::ActiveRecord
6
+ #
7
+ # FourthDimensional.configure do |config|
8
+ # config.event_loader = FourthDimensional::EventLoaders::ActiveRecord.new
9
+ # end
10
+ class ActiveRecord
11
+ # Loads events by +aggregate_id+ and deserializes them.
12
+ #
13
+ # FourthDimensional.config.event_loader.for_aggregate(aggregate_id)
14
+ def for_aggregate(aggregate_id)
15
+ Event.where(aggregate_id: aggregate_id)
16
+ .order(:version)
17
+ .map(&method(:deserialize_event))
18
+ end
19
+
20
+ # Deserializes a single event.
21
+ def deserialize_event(event)
22
+ event.event_type.camelize.constantize.new(aggregate_id: event.aggregate_id,
23
+ id: event.id,
24
+ version: event.version,
25
+ data: event.data,
26
+ metadata: event.metadata,
27
+ created_at: event.created_at,
28
+ updated_at: event.updated_at)
29
+ end
30
+
31
+ # Saves commands and events in active record compatible database.
32
+ def save_commands_and_events(commands:, events:)
33
+ FourthDimensionalRecord.transaction do
34
+ create_commands!(commands)
35
+ create_events!(events).map(&method(:deserialize_event))
36
+ end
37
+ end
38
+
39
+ class FourthDimensionalRecord < ::ActiveRecord::Base # :nodoc:
40
+ self.abstract_class = true
41
+ end
42
+
43
+ class Command < FourthDimensionalRecord # :nodoc:
44
+ self.table_name = FourthDimensional.config.table_prefix + 'commands'
45
+
46
+ serialize :data, JSON
47
+ end
48
+
49
+ class Event < FourthDimensionalRecord # :nodoc:
50
+ self.table_name = FourthDimensional.config.table_prefix + 'events'
51
+
52
+ serialize :data, JSON
53
+ serialize :metadata, JSON
54
+ end
55
+
56
+ private
57
+
58
+ def create_commands!(commands)
59
+ commands.each do |command|
60
+ Command.create!(aggregate_id: command.aggregate_id,
61
+ command_type: command.class.name.underscore,
62
+ data: command.to_h)
63
+ end
64
+ end
65
+
66
+ def create_events!(events)
67
+ versions = aggregate_versions
68
+ events.map do |event|
69
+ version = versions[event.aggregate_id] += 1
70
+ Event.create!(uuid: SecureRandom.uuid,
71
+ aggregate_id: event.aggregate_id,
72
+ version: version,
73
+ event_type: event.type,
74
+ data: event.data,
75
+ metadata: event.metadata)
76
+ end
77
+ end
78
+
79
+ def aggregate_versions
80
+ Hash.new { |hash, key| hash[key] = 0 }
81
+ .merge(Event.group(:aggregate_id).maximum(:version).to_h)
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,56 @@
1
+ module FourthDimensional
2
+ # == Eventable
3
+ #
4
+ # Provides an api for registering event bindings.
5
+ #
6
+ # class CantHandleTheTruth
7
+ # include FourthDimensional::Eventable
8
+ #
9
+ # on TheTruth do |event|
10
+ # raise RunTimeError.new("an error occured that can not be rescued")
11
+ # end
12
+ # end
13
+ module Eventable
14
+ def self.included(klass)
15
+ klass.extend ClassMethods
16
+ end
17
+
18
+ module ClassMethods
19
+ # Binds an event to the aggregate. Raises a +KeyError+ if the event has
20
+ # already been bound.
21
+ #
22
+ # Post.on(PostAdded, -> (event) {})
23
+ # Post.on(PostAdded, -> (event) {}) # => raises KeyError
24
+ def on(klass, &block)
25
+ if event_bindings.has_key?(klass)
26
+ raise KeyError.new("#{klass.name} is already bound on #{self.name}")
27
+ end
28
+
29
+ event_bindings[klass] = block
30
+ end
31
+
32
+ # Returns an array of class names for the bound events.
33
+ #
34
+ # Post.on(PostAdded, -> (event) {})
35
+ # Post.on(PostDeleted, -> (event) {})
36
+ #
37
+ # Post.events # => [PostAdded, PostDeleted]
38
+ def events
39
+ event_bindings.keys
40
+ end
41
+
42
+ # Returns a hash of event classes and the callback.
43
+ #
44
+ # Post.on(PostAdded, -> (event) {})
45
+ # Post.on(PostDeleted, -> (event) {})
46
+ #
47
+ # Post.event_bindings # => {
48
+ # PostAdded => Proc,
49
+ # PostDeleted => Proc
50
+ # }
51
+ def event_bindings
52
+ @event_bindings ||= {}
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,4 @@
1
+ module Fourthdimensional
2
+ class Railtie < ::Rails::Railtie
3
+ end
4
+ end
@@ -0,0 +1,60 @@
1
+ require 'active_support/core_ext/class/attribute'
2
+
3
+ module FourthDimensional
4
+ # == FourthDimensional::RecordProjector
5
+ #
6
+ # class PostProjector < FourthDimensional::RecordProjector
7
+ # self.record_class = 'Post'
8
+ #
9
+ # on TitleChanged do |event|
10
+ # record.title = event.title
11
+ # end
12
+ # end
13
+ class RecordProjector
14
+ include Eventable
15
+
16
+ # Record class this projector creates
17
+ class_attribute :record_class
18
+
19
+ # The current instance of the projected record
20
+ attr_reader :record
21
+
22
+ def initialize(aggregate_id:)
23
+ @record = record_class.constantize.find_or_initialize_by(id: aggregate_id)
24
+ end
25
+
26
+ # Invokes the event binding.
27
+ #
28
+ # post_projector.apply_event(TitleChanged.new(aggregate_id: aggregate_id,
29
+ # data: {title: 'new-post-title'}))
30
+ def apply_event(event)
31
+ callback = self.class.event_bindings[event.class]
32
+ return if callback.nil?
33
+ instance_exec(event, &callback)
34
+ end
35
+
36
+ # Saves the record at it's current state.
37
+ #
38
+ # post_projector.save
39
+ def save
40
+ record.save
41
+ end
42
+
43
+ # Applies multiple events and saves at the end.
44
+ #
45
+ # projector.record.persisted? # => false
46
+ # projector.call(event1, event2)
47
+ # projector.record.persisted? # => true
48
+ def call(*events)
49
+ events.flatten.map(&method(:apply_event))
50
+ save
51
+ end
52
+
53
+ # Bulk apply and save events to multiple aggregates.
54
+ def self.call(*events)
55
+ events.flatten.group_by(&:aggregate_id).each do |aggregate_id, events|
56
+ new(aggregate_id: aggregate_id).call(events)
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,87 @@
1
+ module FourthDimensional
2
+ # == FourthDimensional::Repository
3
+ #
4
+ # Event sourcing is a good application for the repository pattern since we
5
+ # need to have a single source track commands and events being applied to the
6
+ # system.
7
+ #
8
+ # The FourthDimensional::Repository is a wrapper around loading and persisting
9
+ # events/commands with dependency injection. This allows new repositories to
10
+ # be defined and easily registered.
11
+ #
12
+ # The loading and persisting of events/commands are separated to allow
13
+ # separating the reading and writing databases should that become necessary.
14
+ #
15
+ # class InMemoryEvents
16
+ # attr_reader :events
17
+ #
18
+ # def initialize
19
+ # @events = []
20
+ # end
21
+ #
22
+ # def self.instance
23
+ # @instance ||= InMemoryEvents.new
24
+ # end
25
+ # end
26
+ #
27
+ # class InMemoryEventLoader
28
+ # def for_aggregate(aggregate_id)
29
+ # InMemoryEvents.events
30
+ # .filter { |event| event.aggregate_id == aggregate_id }
31
+ # end
32
+ #
33
+ # def save_commands_and_events(commands:, events:)
34
+ # InMemoryEvents.events.concat(events)
35
+ # end
36
+ # end
37
+ #
38
+ # FourthDimensional.configure do |config|
39
+ # config.event_loader = InMemoryEventLoader.new
40
+ # end
41
+ class Repository
42
+ # The source to load events
43
+ attr_reader :event_loader
44
+
45
+ # An array of events saved
46
+ attr_reader :applied_events
47
+
48
+ # An array of commands called
49
+ attr_reader :called_commands
50
+
51
+ def initialize(event_loader:)
52
+ @event_loader = event_loader
53
+ @applied_events = []
54
+ @called_commands = []
55
+ end
56
+
57
+ # Delegates to +event_loader#for_aggregate+
58
+ #
59
+ # FourthDimensional.repository.events_for_aggregate(aggregate_id)
60
+ def events_for_aggregate(aggregate_id)
61
+ event_loader.for_aggregate(aggregate_id)
62
+ end
63
+
64
+ # Loads events from +event_loader+ and applies them to a new instance of
65
+ # +aggregate_class+
66
+ #
67
+ # FourthDimensional.repository.load_aggregate(PostAggregate, aggregate_id) # => PostAggregate
68
+ def load_aggregate(aggregate_class, aggregate_id)
69
+ events_for_aggregate(aggregate_id)
70
+ .reduce(aggregate_class.new(id: aggregate_id)) do |aggregate, event|
71
+ aggregate.apply_existing_event(event)
72
+ aggregate
73
+ end
74
+ end
75
+
76
+ # Saves the command and events with the +event_loader+
77
+ #
78
+ # repository.save_command_and_events(FourthDimensional::CommandHandler::CommandAndEvents.new(
79
+ # command: AddPost,
80
+ # events: [PostAdded]
81
+ # ))
82
+ def save_command_and_events(command_and_events)
83
+ called_commands << command_and_events.command
84
+ applied_events.concat(command_and_events.events)
85
+ end
86
+ end
87
+ end
@@ -1,3 +1,3 @@
1
1
  module FourthDimensional
2
- VERSION = "0.1.0"
2
+ VERSION = "0.1.1"
3
3
  end
@@ -0,0 +1,11 @@
1
+ require 'rails/generators'
2
+
3
+ module FourthDimensional
4
+ class InstallGenerator < ::Rails::Generators::Base
5
+ source_root File.expand_path('templates', __dir__)
6
+
7
+ def initializer
8
+ template 'initializer.rb', 'config/initializers/fourth_dimensional.rb'
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,29 @@
1
+ require 'rails/generators'
2
+ require 'rails/generators/active_record'
3
+
4
+ module FourthDimensional
5
+ class MigrationGenerator < ::Rails::Generators::Base
6
+ include ::Rails::Generators::Migration
7
+ source_root File.expand_path('templates', __dir__)
8
+
9
+ def install
10
+ migration_template(
11
+ 'migration.rb.erb',
12
+ 'db/migrate/create_fourth_dimensional_tables.rb',
13
+ migration_version: migration_version
14
+ )
15
+ end
16
+
17
+ def self.next_migration_number(dirname)
18
+ ActiveRecord::Generators::Base.next_migration_number(dirname)
19
+ end
20
+
21
+ private
22
+
23
+ def migration_version
24
+ if ActiveRecord::VERSION::MAJOR >= 5
25
+ "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,7 @@
1
+ Rails.configuration.to_prepare do
2
+ FourthDimensional.configure do |config|
3
+ config.event_loader = FourthDimensional::EventLoaders::ActiveRecord.new
4
+ config.command_handlers = []
5
+ config.event_handlers = []
6
+ end
7
+ end
metadata CHANGED
@@ -1,15 +1,57 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fourth_dimensional
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Baylor Rae'
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2019-02-22 00:00:00.000000000 Z
11
+ date: 2019-02-27 00:00:00.000000000 Z
12
12
  dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activemodel
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '3.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '3.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: activesupport
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '3.0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '3.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: activerecord
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '3.0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '3.0'
13
55
  - !ruby/object:Gem::Dependency
14
56
  name: bundler
15
57
  requirement: !ruby/object:Gem::Requirement
@@ -66,6 +108,40 @@ dependencies:
66
108
  - - "~>"
67
109
  - !ruby/object:Gem::Version
68
110
  version: '1.0'
111
+ - !ruby/object:Gem::Dependency
112
+ name: simplecov
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - "~>"
116
+ - !ruby/object:Gem::Version
117
+ version: '0.16'
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - "~>"
123
+ - !ruby/object:Gem::Version
124
+ version: '0.16'
125
+ - !ruby/object:Gem::Dependency
126
+ name: sqlite3
127
+ requirement: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - "~>"
130
+ - !ruby/object:Gem::Version
131
+ version: '1.3'
132
+ - - "<"
133
+ - !ruby/object:Gem::Version
134
+ version: '1.4'
135
+ type: :development
136
+ prerelease: false
137
+ version_requirements: !ruby/object:Gem::Requirement
138
+ requirements:
139
+ - - "~>"
140
+ - !ruby/object:Gem::Version
141
+ version: '1.3'
142
+ - - "<"
143
+ - !ruby/object:Gem::Version
144
+ version: '1.4'
69
145
  description: |-
70
146
  Fourth Dimensional is an event sourcing library to account for the state of a
71
147
  system in relation to time.
@@ -75,19 +151,22 @@ executables: []
75
151
  extensions: []
76
152
  extra_rdoc_files: []
77
153
  files:
78
- - ".gitignore"
79
- - ".rspec"
80
- - ".travis.yml"
81
- - Gemfile
82
- - Gemfile.lock
83
- - README.md
84
- - Rakefile
85
- - bin/console
86
- - bin/setup
87
- - fourth_dimensional.gemspec
88
154
  - lib/fourth_dimensional.rb
155
+ - lib/fourth_dimensional/aggregate_root.rb
156
+ - lib/fourth_dimensional/command.rb
157
+ - lib/fourth_dimensional/command_handler.rb
158
+ - lib/fourth_dimensional/configuration.rb
89
159
  - lib/fourth_dimensional/event.rb
160
+ - lib/fourth_dimensional/event_loaders.rb
161
+ - lib/fourth_dimensional/event_loaders/active_record.rb
162
+ - lib/fourth_dimensional/eventable.rb
163
+ - lib/fourth_dimensional/railtie.rb
164
+ - lib/fourth_dimensional/record_projector.rb
165
+ - lib/fourth_dimensional/repository.rb
90
166
  - lib/fourth_dimensional/version.rb
167
+ - lib/generators/fourth_dimensional/install_generator.rb
168
+ - lib/generators/fourth_dimensional/migration_generator.rb
169
+ - lib/generators/fourth_dimensional/templates/initializer.rb
91
170
  homepage: https://github.com/baylorrae/fourth_dimensional
92
171
  licenses: []
93
172
  metadata: {}
data/.gitignore DELETED
@@ -1,11 +0,0 @@
1
- /.bundle/
2
- /.yardoc
3
- /_yardoc/
4
- /coverage/
5
- /doc/
6
- /pkg/
7
- /spec/reports/
8
- /tmp/
9
-
10
- # rspec failure tracking
11
- .rspec_status
data/.rspec DELETED
@@ -1,3 +0,0 @@
1
- --format documentation
2
- --color
3
- --require spec_helper
@@ -1,15 +0,0 @@
1
- ---
2
- sudo: false
3
- language: ruby
4
- cache: bundler
5
- rvm:
6
- - 2.6.1
7
- before_install: gem install bundler -v 1.17.2
8
- deploy:
9
- local-dir: ./doc
10
- provider: pages
11
- skip-cleanup: true
12
- github-token: $GITHUB_TOKEN
13
- keep-history: true
14
- on:
15
- branch: master
data/Gemfile DELETED
@@ -1,6 +0,0 @@
1
- source "https://rubygems.org"
2
-
3
- git_source(:github) {|repo_name| "https://github.com/#{repo_name}" }
4
-
5
- # Specify your gem's dependencies in fourth_dimensional.gemspec
6
- gemspec
@@ -1,39 +0,0 @@
1
- PATH
2
- remote: .
3
- specs:
4
- fourth_dimensional (0.1.0)
5
-
6
- GEM
7
- remote: https://rubygems.org/
8
- specs:
9
- diff-lcs (1.3)
10
- rake (10.5.0)
11
- rdoc (6.1.1)
12
- rspec (3.8.0)
13
- rspec-core (~> 3.8.0)
14
- rspec-expectations (~> 3.8.0)
15
- rspec-mocks (~> 3.8.0)
16
- rspec-core (3.8.0)
17
- rspec-support (~> 3.8.0)
18
- rspec-expectations (3.8.2)
19
- diff-lcs (>= 1.2.0, < 2.0)
20
- rspec-support (~> 3.8.0)
21
- rspec-mocks (3.8.0)
22
- diff-lcs (>= 1.2.0, < 2.0)
23
- rspec-support (~> 3.8.0)
24
- rspec-support (3.8.0)
25
- sdoc (1.0.0)
26
- rdoc (>= 5.0)
27
-
28
- PLATFORMS
29
- ruby
30
-
31
- DEPENDENCIES
32
- bundler (~> 1.17)
33
- fourth_dimensional!
34
- rake (~> 10.0)
35
- rspec (~> 3.0)
36
- sdoc (~> 1.0)
37
-
38
- BUNDLED WITH
39
- 1.17.2
data/README.md DELETED
@@ -1,123 +0,0 @@
1
- # Fourth Dimensional
2
-
3
- Fourth Dimensional is an event sourcing library to account for the state of a
4
- system in relation to time.
5
-
6
- This project is a lightweight dsl for commands and events for use with
7
- ActiveRecord. Eventually I'd like it to support Sequel as well.
8
-
9
- [RDoc][rdoc_url]
10
-
11
- [![Build Status]][travis_status]
12
-
13
- ## Installation
14
-
15
- Add this line to your application's Gemfile:
16
-
17
- ```ruby
18
- gem 'fourth_dimensional'
19
- ```
20
-
21
- And then execute:
22
-
23
- $ bundle
24
-
25
- Or install it yourself as:
26
-
27
- $ gem install fourth_dimensional
28
-
29
- ## Usage
30
-
31
- This API is in flux, but here's the general idea.
32
-
33
- ```ruby
34
- class PostAdded < FourthDimensional::Event
35
- def title
36
- data.fetch('title')
37
- end
38
-
39
- def body
40
- data.fetch('body')
41
- end
42
-
43
- def published
44
- data.fetch('published')
45
- end
46
- end
47
-
48
- class AddPost < FourthDimensional::Command
49
- attributes :title, :body, :published
50
- validates_presence_of :title, :body
51
- end
52
-
53
- class PostCommandHandler < FourthDimensional::CommandHandler
54
- on AddPost do |command|
55
- with_aggregate(PostAggregate, command) do |post|
56
- post.add(title: command.title,
57
- body: command.body,
58
- published: command.published)
59
- end
60
- end
61
- end
62
-
63
- class PostAggregate < FourthDimensional::AggregateRoot
64
- def add(title:, body:, published:)
65
- apply(PostAdded,
66
- data: {
67
- title: title,
68
- body: body,
69
- published: published
70
- }
71
- )
72
- end
73
- end
74
-
75
- class PostProjector < FourthDimensional::RecordProjector
76
- self.record_class = 'Post'
77
-
78
- on PostAdded do |event|
79
- record.title = event.title
80
- record.body = event.body
81
- record.published = event.published
82
- end
83
- end
84
-
85
- def main
86
- FourthDimensional.configure do |config|
87
- config.event_handlers = [
88
- PostCommandHandler,
89
- PostProjector
90
- ]
91
- end
92
-
93
- aggregate_id = SecureRandom.uuid
94
-
95
- command = AddPost.new(aggregate_id: aggregate_id,
96
- title: 'post-title',
97
- body: 'post-body',
98
- published: true)
99
-
100
- # persists command and event if successful
101
- FourthDimensional.execute_commands(command)
102
-
103
- post = Post.find(aggregate_id)
104
- expect(post.title).to eq('post-title')
105
- expect(post.body).to eq('post-body')
106
- expect(post.published).to be_truthy
107
- end
108
- ```
109
-
110
- ## Development
111
-
112
- After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
113
-
114
- To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
115
-
116
- ## Contributing
117
-
118
- Bug reports and pull requests are welcome on GitHub at https://github.com/baylorrae/fourth_dimensional.
119
-
120
- [rdoc_url]: https://baylorrae.com/fourth_dimensional
121
-
122
- [Build Status]: https://travis-ci.org/BaylorRae/fourth_dimensional.svg?branch=master
123
- [travis_status]: https://travis-ci.org/BaylorRae/fourth_dimensional
data/Rakefile DELETED
@@ -1,16 +0,0 @@
1
- require "bundler/gem_tasks"
2
- require "rspec/core/rake_task"
3
- require "sdoc"
4
- require "rdoc/task"
5
-
6
- RSpec::Core::RakeTask.new(:spec)
7
-
8
- RDoc::Task.new do |rdoc|
9
- rdoc.rdoc_dir = 'doc'
10
- rdoc.options << '--format=sdoc'
11
- rdoc.options << '--github'
12
- rdoc.main = 'README.md'
13
- rdoc.rdoc_files.include('README.md', 'lib/**/*.rb')
14
- end
15
-
16
- task :default => [:spec, :rdoc]
@@ -1,14 +0,0 @@
1
- #!/usr/bin/env ruby
2
-
3
- require "bundler/setup"
4
- require "fourth_dimensional"
5
-
6
- # You can add fixtures and/or initialization code here to make experimenting
7
- # with your gem easier. You can also use a different console, if you like.
8
-
9
- # (If you use this, don't forget to add pry to your Gemfile!)
10
- # require "pry"
11
- # Pry.start
12
-
13
- require "irb"
14
- IRB.start(__FILE__)
data/bin/setup DELETED
@@ -1,8 +0,0 @@
1
- #!/usr/bin/env bash
2
- set -euo pipefail
3
- IFS=$'\n\t'
4
- set -vx
5
-
6
- bundle install
7
-
8
- # Do any other automated setup that you need to do here
@@ -1,31 +0,0 @@
1
-
2
- lib = File.expand_path("../lib", __FILE__)
3
- $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
- require "fourth_dimensional/version"
5
-
6
- Gem::Specification.new do |spec|
7
- spec.name = "fourth_dimensional"
8
- spec.version = FourthDimensional::VERSION
9
- spec.authors = ["Baylor Rae'"]
10
- spec.email = ["baylor@thecodedeli.com"]
11
-
12
- spec.summary = %q{Fourth Dimensional is an event sourcing library to account for the state of a
13
- system in relation to time.}
14
- spec.description = %q{Fourth Dimensional is an event sourcing library to account for the state of a
15
- system in relation to time.}
16
- spec.homepage = "https://github.com/baylorrae/fourth_dimensional"
17
-
18
- # Specify which files should be added to the gem when it is released.
19
- # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
20
- spec.files = Dir.chdir(File.expand_path('..', __FILE__)) do
21
- `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
22
- end
23
- spec.bindir = "exe"
24
- spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
25
- spec.require_paths = ["lib"]
26
-
27
- spec.add_development_dependency "bundler", "~> 1.17"
28
- spec.add_development_dependency "rake", "~> 10.0"
29
- spec.add_development_dependency "rspec", "~> 3.0"
30
- spec.add_development_dependency "sdoc", "~> 1.0"
31
- end