sequent 3.2.2 → 4.0.0

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.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/lib/notices.rb +4 -0
  3. data/lib/sequent.rb +3 -0
  4. data/lib/sequent/application_record.rb +7 -0
  5. data/lib/sequent/configuration.rb +13 -0
  6. data/lib/sequent/core/aggregate_repository.rb +7 -1
  7. data/lib/sequent/core/command.rb +13 -2
  8. data/lib/sequent/core/command_record.rb +5 -2
  9. data/lib/sequent/core/command_service.rb +28 -12
  10. data/lib/sequent/core/event_publisher.rb +4 -0
  11. data/lib/sequent/core/event_record.rb +2 -1
  12. data/lib/sequent/core/event_store.rb +23 -4
  13. data/lib/sequent/core/helpers/attribute_support.rb +28 -7
  14. data/lib/sequent/core/helpers/mergable.rb +1 -0
  15. data/lib/sequent/core/persistors/active_record_persistor.rb +1 -1
  16. data/lib/sequent/core/persistors/replay_optimized_postgres_persistor.rb +2 -2
  17. data/lib/sequent/core/projector.rb +23 -1
  18. data/lib/sequent/core/stream_record.rb +1 -1
  19. data/lib/sequent/core/transactions/active_record_transaction_provider.rb +6 -4
  20. data/lib/sequent/generator.rb +1 -4
  21. data/lib/sequent/generator/generator.rb +4 -0
  22. data/lib/sequent/generator/project.rb +1 -1
  23. data/lib/sequent/generator/template_project/Gemfile +1 -1
  24. data/lib/sequent/generator/template_project/app/records/post_record.rb +1 -1
  25. data/lib/sequent/generator/template_project/spec/app/projectors/post_projector_spec.rb +1 -1
  26. data/lib/sequent/generator/template_project/spec/lib/post/post_command_handler_spec.rb +1 -1
  27. data/lib/sequent/migrations/executor.rb +78 -0
  28. data/lib/sequent/migrations/functions.rb +76 -0
  29. data/lib/sequent/migrations/migrations.rb +1 -0
  30. data/lib/sequent/migrations/planner.rb +118 -0
  31. data/lib/sequent/migrations/projectors.rb +6 -5
  32. data/lib/sequent/migrations/sql.rb +17 -0
  33. data/lib/sequent/migrations/view_schema.rb +74 -73
  34. data/lib/sequent/rake/migration_tasks.rb +2 -2
  35. data/lib/sequent/rake/tasks.rb +1 -1
  36. data/lib/sequent/sequent.rb +5 -1
  37. data/lib/sequent/support/database.rb +11 -6
  38. data/lib/sequent/test/command_handler_helpers.rb +4 -0
  39. data/lib/sequent/util/dry_run.rb +191 -0
  40. data/lib/sequent/util/skip_if_already_processing.rb +19 -5
  41. data/lib/sequent/util/util.rb +1 -0
  42. data/lib/version.rb +1 -1
  43. metadata +77 -36
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 10b5839af491a1fc854e512051185f6141b6bff465d4aea09fe7c6f20940ba4e
4
- data.tar.gz: 32bccd0cc5b44a760e544a48b3be9578abcc9f9de7fbd083ca9ed6f38c5e5932
3
+ metadata.gz: 3322a4b950e847a5818555b2718df70e2b81a8e398074309a0b58c2a8d332ce5
4
+ data.tar.gz: 6136697a8a9596e999fabf3814509428b06188a0ac577fa3443a80ec190cbdd7
5
5
  SHA512:
6
- metadata.gz: 6c45431a49ad4100a7ad0ebe812aecb75144a9bf847823268a98b85c96689948e83fcfa76fd1d0c66dea5dabe923d41ab820ceefeb07cf2605d2b98296ebe7d6
7
- data.tar.gz: 3198cc99cf9bfa8ba955aecc4fd85fd7559f848a83c64c77d7a7092fafeb7c2aadfec4e31f587c929304537e306e2f5cebfcbbd780c14700c9b384daeaede68e
6
+ metadata.gz: f8ce675ae0a16274630066192086fb8c46bb0306e9280fd4b8901500af4a7af56844c5d6c40cd0f45a87342c33a4faeb2eee48f0dc0fb05a0a09046a2dea7414
7
+ data.tar.gz: e750e54d72de1c8641a513839c675adeb32fbeaa3435528a8aca755a951a7572d696e9348b7594e104f044978a0e9780f8e080ff1ad5e4ea9b7cc203fac6fee2
data/lib/notices.rb ADDED
@@ -0,0 +1,4 @@
1
+ # This file is for any notices such as deprecation warnings, which should appear
2
+ # in the logs during app boot. Adding such warnings in other places causes
3
+ # lots of noise with duplicated messages, whereas this file is only
4
+ # run once.
data/lib/sequent.rb CHANGED
@@ -1,4 +1,7 @@
1
+ require_relative 'sequent/application_record'
1
2
  require_relative 'sequent/sequent'
2
3
  require_relative 'sequent/core/core'
3
4
  require_relative 'sequent/util/util'
4
5
  require_relative 'sequent/migrations/migrations'
6
+
7
+ require_relative 'notices'
@@ -0,0 +1,7 @@
1
+ require 'active_record'
2
+
3
+ module Sequent
4
+ class ApplicationRecord < ActiveRecord::Base
5
+ self.abstract_class = true
6
+ end
7
+ end
@@ -13,6 +13,7 @@ module Sequent
13
13
 
14
14
  DEFAULT_MIGRATION_SQL_FILES_DIRECTORY = 'db/tables'
15
15
  DEFAULT_DATABASE_CONFIG_DIRECTORY = 'db'
16
+ DEFAULT_DATABASE_SCHEMA_DIRECTORY = 'db'
16
17
 
17
18
  DEFAULT_VIEW_SCHEMA_NAME = 'view_schema'
18
19
  DEFAULT_EVENT_STORE_SCHEMA_NAME= 'sequent_schema'
@@ -26,6 +27,10 @@ module Sequent
26
27
 
27
28
  DEFAULT_EVENT_RECORD_HOOKS_CLASS = Sequent::Core::EventRecordHooks
28
29
 
30
+ DEFAULT_STRICT_CHECK_ATTRIBUTES_ON_APPLY_EVENTS = false
31
+
32
+ DEFAULT_ERROR_LOCALE_RESOLVER = -> { I18n.locale || :en }
33
+
29
34
  attr_accessor :aggregate_repository
30
35
 
31
36
  attr_accessor :event_store,
@@ -49,14 +54,19 @@ module Sequent
49
54
 
50
55
  attr_accessor :logger
51
56
 
57
+ attr_accessor :error_locale_resolver
58
+
52
59
  attr_accessor :migration_sql_files_directory,
53
60
  :view_schema_name,
54
61
  :offline_replay_persistor_class,
55
62
  :online_replay_persistor_class,
56
63
  :number_of_replay_processes,
57
64
  :database_config_directory,
65
+ :database_schema_directory,
58
66
  :event_store_schema_name
59
67
 
68
+ attr_accessor :strict_check_attributes_on_apply_events
69
+
60
70
  attr_reader :migrations_class_name,
61
71
  :versions_table_name,
62
72
  :replayed_ids_table_name
@@ -101,8 +111,11 @@ module Sequent
101
111
  self.offline_replay_persistor_class = DEFAULT_OFFLINE_REPLAY_PERSISTOR_CLASS
102
112
  self.online_replay_persistor_class = DEFAULT_ONLINE_REPLAY_PERSISTOR_CLASS
103
113
  self.database_config_directory = DEFAULT_DATABASE_CONFIG_DIRECTORY
114
+ self.database_schema_directory = DEFAULT_DATABASE_SCHEMA_DIRECTORY
115
+ self.strict_check_attributes_on_apply_events = DEFAULT_STRICT_CHECK_ATTRIBUTES_ON_APPLY_EVENTS
104
116
 
105
117
  self.logger = Logger.new(STDOUT).tap {|l| l.level = Logger::INFO }
118
+ self.error_locale_resolver = DEFAULT_ERROR_LOCALE_RESOLVER
106
119
  end
107
120
 
108
121
  def replayed_ids_table_name=(table_name)
@@ -100,11 +100,17 @@ module Sequent
100
100
  ##
101
101
  # Returns whether the event store has an aggregate with the given id
102
102
  def contains_aggregate?(aggregate_id)
103
- Sequent.configuration.event_store.stream_exists?(aggregate_id)
103
+ Sequent.configuration.event_store.stream_exists?(aggregate_id) &&
104
+ Sequent.configuration.event_store.events_exists?(aggregate_id)
104
105
  end
105
106
 
106
107
  # Gets all uncommitted_events from the 'registered' aggregates
107
108
  # and stores them in the event store.
109
+ #
110
+ # The events given to the EventStore are ordered in loading order
111
+ # of the different AggregateRoot's. So Events are stored
112
+ # (and therefore published) in order in which they are `apply`-ed per AggregateRoot.
113
+ #
108
114
  # The command is 'attached' for traceability purpose so we can see
109
115
  # which command resulted in which events.
110
116
  #
@@ -7,7 +7,15 @@ require_relative 'helpers/mergable'
7
7
 
8
8
  module Sequent
9
9
  module Core
10
- # Base command
10
+ #
11
+ # Base class for all Command's.
12
+ #
13
+ # Commands form the API of your domain. They are
14
+ # simple data objects with descriptive names
15
+ # of what they want to achieve. E.g. `SendInvoice`.
16
+ #
17
+ # BaseCommand uses `ActiveModel::Validations` for
18
+ # validations
11
19
  class BaseCommand
12
20
  include ActiveModel::Validations,
13
21
  Sequent::Core::Helpers::Copyable,
@@ -40,6 +48,9 @@ module Sequent
40
48
  end
41
49
  end
42
50
 
51
+ #
52
+ # Utility class containing all subclasses of BaseCommand
53
+ #
43
54
  class Commands
44
55
  class << self
45
56
  def commands
@@ -60,7 +71,7 @@ module Sequent
60
71
  end
61
72
  end
62
73
 
63
- # Most commonly used command
74
+ # Most commonly used Command
64
75
  # Command can be instantiated just by using:
65
76
  #
66
77
  # Command.new(aggregate_id: "1", user_id: "joe")
@@ -32,7 +32,7 @@ module Sequent
32
32
  end
33
33
 
34
34
  # For storing Sequent::Core::Command in the database using active_record
35
- class CommandRecord < ActiveRecord::Base
35
+ class CommandRecord < Sequent::ApplicationRecord
36
36
  include SerializesCommand
37
37
 
38
38
  self.table_name = "command_records"
@@ -42,7 +42,10 @@ module Sequent
42
42
  validates_presence_of :command_type, :command_json
43
43
 
44
44
  def parent
45
- EventRecord.find_by(aggregate_id: event_aggregate_id, sequence_number: event_sequence_number)
45
+ EventRecord
46
+ .where(aggregate_id: event_aggregate_id, sequence_number: event_sequence_number)
47
+ .where('event_type != ?', Sequent::Core::SnapshotEvent.name)
48
+ .first
46
49
  end
47
50
 
48
51
  def children
@@ -4,23 +4,29 @@ require_relative 'current_event'
4
4
  module Sequent
5
5
  module Core
6
6
  #
7
- # Single point in the application where subclasses of Sequent::Core::BaseCommand
8
- # are executed. This will initiate the entire flow of:
7
+ # Single point in the application to get something done in Sequent.
8
+ # The CommandService handles all subclasses Sequent::Core::BaseCommand. Most common
9
+ # use is to subclass `Sequent::Command`.
9
10
  #
10
- # * Validate command
11
- # * Call correct Sequent::Core::BaseCommandHandler
12
- # * CommandHandler decides which Sequent::Core::AggregateRoot (s) to call
13
- # * Events are stored in the Sequent::Core::EventStore
14
- # * Unit of Work is cleared
11
+ # The CommandService is available via the shortcut method `Sequent.command_service`
12
+ #
13
+ # To use the CommandService please use:
14
+ #
15
+ # Sequent.command_service.execute_commands(...)
15
16
  #
16
17
  class CommandService
18
+ #
17
19
  # Executes the given commands in a single transactional block as implemented by the +transaction_provider+
18
20
  #
19
- # For each command:
21
+ # For each Command:
22
+ #
23
+ # * Validate command
24
+ # * Call Sequent::CommandHandler's listening to the given Command
25
+ # * Store and publish Events
26
+ # * Any new Command's (from e.g. workflows) are queued for processing in the same transaction
27
+ #
28
+ # At the end the transaction is committed and the AggregateRepository's Unit of Work is cleared.
20
29
  #
21
- # * All filters are executed. Any exception raised will rollback the transaction and propagate up
22
- # * If the command is valid all +command_handlers+ that +handles_message?+ is invoked
23
- # * The +repository+ commits the command and all uncommitted_events resulting from the command
24
30
  def execute_commands(*commands)
25
31
  commands.each do |command|
26
32
  if command.respond_to?(:event_aggregate_id) && CurrentEvent.current
@@ -33,6 +39,7 @@ module Sequent
33
39
  end
34
40
 
35
41
  def remove_event_handler(clazz)
42
+ warn "[DEPRECATION] `remove_event_handler` is deprecated"
36
43
  event_store.remove_event_handler(clazz)
37
44
  end
38
45
 
@@ -45,6 +52,7 @@ module Sequent
45
52
  while(!command_queue.empty?) do
46
53
  process_command(command_queue.pop)
47
54
  end
55
+ Sequent::Util.done_processing(:command_service_process_commands)
48
56
  end
49
57
  ensure
50
58
  command_queue.clear
@@ -54,9 +62,16 @@ module Sequent
54
62
  end
55
63
 
56
64
  def process_command(command)
65
+ fail ArgumentError, 'command is required' if command.nil?
66
+
67
+ Sequent.logger.debug("[CommandService] Processing command #{command.class}")
68
+
57
69
  filters.each { |filter| filter.execute(command) }
58
70
 
59
- raise CommandNotValid.new(command) unless command.valid?
71
+ I18n.with_locale(Sequent.configuration.error_locale_resolver.call) do
72
+ raise CommandNotValid.new(command) unless command.valid?
73
+ end
74
+
60
75
  parsed_command = command.parse_attrs_to_correct_types
61
76
  command_handlers.select { |h| h.class.handles_message?(parsed_command) }.each { |h| h.handle_message parsed_command }
62
77
  repository.commit(parsed_command)
@@ -89,6 +104,7 @@ module Sequent
89
104
 
90
105
  # Raised when BaseCommand.valid? returns false
91
106
  class CommandNotValid < ArgumentError
107
+ attr_reader :command
92
108
 
93
109
  def initialize(command)
94
110
  @command = command
@@ -46,6 +46,10 @@ module Sequent
46
46
  end
47
47
 
48
48
  def process_event(event)
49
+ fail ArgumentError, 'event is required' if event.nil?
50
+
51
+ Sequent.logger.debug("[EventPublisher] Publishing event #{event.class}")
52
+
49
53
  configuration.event_handlers.each do |handler|
50
54
  begin
51
55
  handler.handle_message event
@@ -1,5 +1,6 @@
1
1
  require 'active_record'
2
2
  require_relative 'sequent_oj'
3
+ require_relative '../application_record.rb'
3
4
 
4
5
  module Sequent
5
6
  module Core
@@ -72,7 +73,7 @@ module Sequent
72
73
  end
73
74
  end
74
75
 
75
- class EventRecord < ActiveRecord::Base
76
+ class EventRecord < Sequent::ApplicationRecord
76
77
  include SerializesEvent
77
78
 
78
79
  self.table_name = "event_records"
@@ -33,10 +33,18 @@ module Sequent
33
33
  # Stores the events in the EventStore and publishes the events
34
34
  # to the registered event_handlers.
35
35
  #
36
- # Streams_with_Events is an enumerable of pairs from
37
- # `StreamRecord` to arrays of uncommitted `Event`s.
36
+ # The events are published according to the order in
37
+ # the tail of the given `streams_with_events` array pair.
38
+ #
39
+ # @param command The command that caused the Events
40
+ # @param streams_with_events is an enumerable of pairs from
41
+ # `StreamRecord` to arrays ordered uncommitted `Event`s.
38
42
  #
39
43
  def commit_events(command, streams_with_events)
44
+ fail ArgumentError, "command is required" if command.nil?
45
+
46
+ Sequent.logger.debug("[EventStore] Committing events for command #{command.class}")
47
+
40
48
  store_events(command, streams_with_events)
41
49
  publish_events(streams_with_events.flat_map { |_, events| events })
42
50
  end
@@ -80,6 +88,9 @@ ORDER BY sequence_number ASC, (CASE event_type WHEN #{quote Sequent.configuratio
80
88
  Sequent.configuration.stream_record_class.exists?(aggregate_id: aggregate_id)
81
89
  end
82
90
 
91
+ def events_exists?(aggregate_id)
92
+ Sequent.configuration.event_record_class.exists?(aggregate_id: aggregate_id)
93
+ end
83
94
  ##
84
95
  # Replays all events in the event store to the registered event_handlers.
85
96
  #
@@ -158,7 +169,15 @@ SELECT aggregate_id
158
169
  private
159
170
 
160
171
  def column_names
161
- @column_names ||= Sequent.configuration.event_record_class.column_names.reject { |c| c == 'id' }
172
+ @column_names ||= Sequent
173
+ .configuration
174
+ .event_record_class
175
+ .column_names
176
+ .reject { |c| c == primary_key_event_records }
177
+ end
178
+
179
+ def primary_key_event_records
180
+ @primary_key_event_records ||= Sequent.configuration.event_record_class.primary_key
162
181
  end
163
182
 
164
183
  def deserialize_event(event_hash)
@@ -200,7 +219,7 @@ SELECT aggregate_id
200
219
  .join(',')
201
220
  columns = column_names.map { |c| connection.quote_column_name(c) }.join(',')
202
221
  sql = %Q{insert into #{connection.quote_table_name(Sequent.configuration.event_record_class.table_name)} (#{columns}) values #{values}}
203
- Sequent.configuration.event_record_class.connection.insert(sql)
222
+ Sequent.configuration.event_record_class.connection.insert(sql, nil, primary_key_event_records)
204
223
  rescue ActiveRecord::RecordNotUnique
205
224
  fail OptimisticLockingError.new
206
225
  end
@@ -9,17 +9,33 @@ require_relative 'association_validator'
9
9
  module Sequent
10
10
  module Core
11
11
  module Helpers
12
- # Provides functionality for defining attributes with their types
12
+ # Provides functionality for defining attributes with their types.
13
13
  #
14
- # Since our Commands and ValueObjects are not backed by a database like e.g. rails
14
+ # Since our Commands and ValueObjects are not backed by a database like e.g. Rails
15
15
  # we can not infer their types. We need the types to be able to parse from and to json.
16
- # We could have stored te type information in the json, but we didn't.
17
- #
18
16
  # You typically do not need to include this module in your classes. If you extend from
19
- # Sequent::Core::ValueObject, Sequent::Core::Event or Sequent::Core::BaseCommand you will
17
+ # Sequent::ValueObject, Sequent::Event or Sequent::Command you will
20
18
  # get this functionality for free.
21
19
  #
20
+ # Example:
21
+ #
22
+ # attrs name: String, age: Integer, born: Date
23
+ #
24
+ # Currently Sequent supports the following types:
25
+ #
26
+ # - String
27
+ # - Integer
28
+ # - Boolean
29
+ # - Date
30
+ # - DateTime
31
+ # - Subclasses of Sequent::ValueObject
32
+ # - Lists defined as `array(String)`
33
+ # - BigDecimal
34
+ # - Sequent::Secret
35
+ #
22
36
  module AttributeSupport
37
+ class UnknownAttributeError < StandardError; end
38
+
23
39
  # module containing class methods to be added
24
40
  module ClassMethods
25
41
 
@@ -60,6 +76,7 @@ module Sequent
60
76
  class_eval <<EOS
61
77
  def update_all_attributes(attrs)
62
78
  super if defined?(super)
79
+ ensure_known_attributes(attrs)
63
80
  #{@types.map { |attribute, _|
64
81
  "@#{attribute} = attrs[:#{attribute}]"
65
82
  }.join("\n ")}
@@ -157,9 +174,13 @@ EOS
157
174
  prefix ? HashWithIndifferentAccess[result.map { |k, v| ["#{prefix}_#{k}", v] }] : result
158
175
  end
159
176
 
160
- end
161
-
177
+ def ensure_known_attributes(attrs)
178
+ return unless Sequent.configuration.strict_check_attributes_on_apply_events
162
179
 
180
+ unknowns = attrs.keys.map(&:to_s) - self.class.types.keys.map(&:to_s)
181
+ raise UnknownAttributeError.new("#{self.class.name} does not specify attrs: #{unknowns.join(", ")}") if unknowns.any?
182
+ end
183
+ end
163
184
  end
164
185
  end
165
186
  end
@@ -8,6 +8,7 @@ module Sequent
8
8
  module Mergable
9
9
 
10
10
  def merge!(attrs = {})
11
+ warn "[DEPRECATION] `merge!` is deprecated. Please use `copy` instead. This method will no longer be included in the next version of Sequent. You can still use it but you will have to include the module `Sequent::Core::Helpers::Mergable` yourself."
11
12
  attrs.each do |name, value|
12
13
  self.send("#{name}=", value)
13
14
  end
@@ -104,7 +104,7 @@ module Sequent
104
104
  end
105
105
 
106
106
  def execute_sql(statement)
107
- ActiveRecord::Base.connection.execute(statement)
107
+ Sequent::ApplicationRecord.connection.execute(statement)
108
108
  end
109
109
 
110
110
  def commit
@@ -307,7 +307,7 @@ module Sequent
307
307
  end
308
308
 
309
309
  buf = ''
310
- conn = ActiveRecord::Base.connection.raw_connection
310
+ conn = Sequent::ApplicationRecord.connection.raw_connection
311
311
  copy_data = StringIO.new csv.string
312
312
  conn.transaction do
313
313
  conn.copy_data("COPY #{clazz.table_name} (#{column_names.join(",")}) FROM STDIN WITH csv") do
@@ -346,7 +346,7 @@ module Sequent
346
346
  private
347
347
 
348
348
  def cast_value_to_column_type(clazz, column_name, record)
349
- ActiveRecord::Base.connection.type_cast(record[column_name.to_sym], @column_cache[clazz.name][column_name])
349
+ Sequent::ApplicationRecord.connection.type_cast(record[column_name.to_sym], @column_cache[clazz.name][column_name])
350
350
  end
351
351
  end
352
352
  end
@@ -11,7 +11,26 @@ module Sequent
11
11
  end
12
12
 
13
13
  def managed_tables
14
- @managed_tables
14
+ @managed_tables || managed_tables_from_superclass
15
+ end
16
+
17
+ def manages_no_tables
18
+ @manages_no_tables = true
19
+ manages_tables *[]
20
+ end
21
+
22
+ def manages_no_tables?
23
+ !!@manages_no_tables || manages_no_tables_from_superclass?
24
+ end
25
+
26
+ private
27
+
28
+ def managed_tables_from_superclass
29
+ self.superclass.managed_tables if self.superclass.respond_to?(:managed_tables)
30
+ end
31
+
32
+ def manages_no_tables_from_superclass?
33
+ self.superclass.manages_no_tables? if self.superclass.respond_to?(:manages_no_tables?)
15
34
  end
16
35
  end
17
36
 
@@ -96,7 +115,10 @@ module Sequent
96
115
  :commit
97
116
 
98
117
  private
118
+
99
119
  def ensure_valid!
120
+ return if self.class.manages_no_tables?
121
+
100
122
  fail "A Projector must manage at least one table. Did you forget to add `managed_tables` to #{self.class.name}?" if self.class.managed_tables.nil? || self.class.managed_tables.empty?
101
123
  end
102
124
  end