yes-core 1.0.0 → 1.2.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +8 -0
- data/lib/yes/core/aggregate/draftable.rb +3 -1
- data/lib/yes/core/aggregate/dsl/command_data.rb +5 -1
- data/lib/yes/core/aggregate.rb +63 -9
- data/lib/yes/core/command_handling/guard_evaluator.rb +23 -0
- data/lib/yes/core/middlewares.rb +17 -0
- data/lib/yes/core/test_support/aggregate/command_test_dsl.rb +112 -0
- data/lib/yes/core/test_support/aggregate/matchers.rb +81 -0
- data/lib/yes/core/test_support/aggregate/shared_examples.rb +77 -0
- data/lib/yes/core/test_support/event_helpers.rb +73 -1
- data/lib/yes/core/test_support.rb +3 -0
- data/lib/yes/core/utils/aggregate_shortcuts.rb +42 -21
- data/lib/yes/core/utils/command_utils.rb +26 -9
- data/lib/yes/core/version.rb +1 -1
- data/lib/yes/core.rb +1 -0
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 684a9c20dfd17ba779b196a7b294b93e04bf531bcc3d31a2adc9f8a4d645b7dd
|
|
4
|
+
data.tar.gz: 43e482658ce60a8c7fe4eb5f73f3bf084169857bd06a57a01aadf5ab7cf98e76
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 335785676b2baef08117225b60c3a3b170bb39b0c3fe8ffdfa2781c158944b562f53d00884f409fb0c07c15ace4b513c02bd042973da57118ec325701a258acb
|
|
7
|
+
data.tar.gz: 72bc5b16b03b00ddc6211806d2566ad5213a25e452836e1ffd09f1e89c10154e98e47edb5ac75573092b1c21ea13cc38f5f49f379f418c2e08c2532d2e498670
|
data/CHANGELOG.md
CHANGED
|
@@ -15,6 +15,7 @@ module Yes
|
|
|
15
15
|
included do
|
|
16
16
|
class << self
|
|
17
17
|
attr_accessor :_draft_context, :_draft_aggregate, :_changes_read_model_name,
|
|
18
|
+
:_changes_read_model_explicit,
|
|
18
19
|
:_draft_foreign_key, :_is_draftable, :_changes_read_model_public
|
|
19
20
|
end
|
|
20
21
|
end
|
|
@@ -52,7 +53,8 @@ module Yes
|
|
|
52
53
|
self._draft_context = draft_config[:context] || context
|
|
53
54
|
self._draft_aggregate = draft_config[:aggregate] || "#{aggregate}Draft"
|
|
54
55
|
|
|
55
|
-
self.
|
|
56
|
+
self._changes_read_model_explicit = changes_read_model.present?
|
|
57
|
+
self._changes_read_model_name = if changes_read_model.present?
|
|
56
58
|
changes_read_model.to_s
|
|
57
59
|
else
|
|
58
60
|
"#{read_model_name}_change"
|
|
@@ -12,7 +12,7 @@ module Yes
|
|
|
12
12
|
class CommandData
|
|
13
13
|
attr_reader :name, :context_name, :aggregate_name, :aggregate_class
|
|
14
14
|
attr_accessor :event_name, :payload_attributes, :update_state_block, :guard_names, :authorizer_block,
|
|
15
|
-
:encrypted_attributes
|
|
15
|
+
:encrypted_attributes, :skip_default_guards
|
|
16
16
|
|
|
17
17
|
# @param name [Symbol] The name of the command
|
|
18
18
|
# @param aggregate_class [Class] The aggregate class this command belongs to
|
|
@@ -21,6 +21,7 @@ module Yes
|
|
|
21
21
|
# @option options [String] :aggregate The aggregate name
|
|
22
22
|
# @option options [String] :event_name The event name for the command
|
|
23
23
|
# @option options [Hash] :payload_attributes The payload attributes for the command
|
|
24
|
+
# @option options [Array<Symbol>] :skip_default_guards Default guards (e.g. :not_removed) that should not be auto-applied
|
|
24
25
|
def initialize(name, aggregate_class, options = {})
|
|
25
26
|
@name = name
|
|
26
27
|
@aggregate_class = aggregate_class
|
|
@@ -38,6 +39,9 @@ module Yes
|
|
|
38
39
|
|
|
39
40
|
# Track encrypted attributes for event schema generation
|
|
40
41
|
@encrypted_attributes = []
|
|
42
|
+
|
|
43
|
+
# Default guards to opt out of (e.g. :not_removed for the :remove command itself)
|
|
44
|
+
@skip_default_guards = options.delete(:skip_default_guards) || []
|
|
41
45
|
end
|
|
42
46
|
|
|
43
47
|
# Add a guard name to the list of guards
|
data/lib/yes/core/aggregate.rb
CHANGED
|
@@ -76,8 +76,15 @@ module Yes
|
|
|
76
76
|
#
|
|
77
77
|
# @param name [Symbol] The name of the parent.
|
|
78
78
|
# @param options [Hash] Options for configuring the parent.
|
|
79
|
+
# @option options [Boolean] :command (true) When false, skips defining the `assign_<name>` command.
|
|
80
|
+
# @option options [Array<Symbol>] :skip_default_guards ([]) Default guards (e.g. `:not_removed`)
|
|
81
|
+
# that should not be auto-applied to the generated `assign_<name>` command. See
|
|
82
|
+
# {.removable} for context on the `:not_removed` auto-block.
|
|
79
83
|
# @yield Block for defining guards and other attribute configurations.
|
|
80
84
|
# @return [void]
|
|
85
|
+
#
|
|
86
|
+
# @example Skip the auto-injected :not_removed guard on a parent's assign command
|
|
87
|
+
# parent :tenant, skip_default_guards: %i[not_removed]
|
|
81
88
|
def parent(name, **options, &)
|
|
82
89
|
parent_aggregates[name] = options
|
|
83
90
|
|
|
@@ -85,7 +92,9 @@ module Yes
|
|
|
85
92
|
|
|
86
93
|
return unless options.fetch(:command, true)
|
|
87
94
|
|
|
88
|
-
|
|
95
|
+
skip_default_guards = options[:skip_default_guards] || []
|
|
96
|
+
|
|
97
|
+
command :"assign_#{name}", skip_default_guards: do
|
|
89
98
|
payload "#{name}_id": :uuid
|
|
90
99
|
|
|
91
100
|
guard(:no_change) { public_send(:"#{name}_id") != payload.public_send(:"#{name}_id") }
|
|
@@ -103,7 +112,26 @@ module Yes
|
|
|
103
112
|
|
|
104
113
|
# Defines a default removal behavior for the aggregate.
|
|
105
114
|
#
|
|
115
|
+
# In addition to defining the `:remove` command, `removable` records aggregate-level
|
|
116
|
+
# configuration that the {Yes::Core::CommandHandling::GuardEvaluator} reads at runtime
|
|
117
|
+
# to **auto-block every other command on the aggregate** while the removal attribute is
|
|
118
|
+
# set. The auto-block fires before any registered guard (including the auto-injected
|
|
119
|
+
# `:no_change`), so post-remove mutations consistently raise
|
|
120
|
+
# `GuardEvaluator::InvalidTransition` with the i18n message under
|
|
121
|
+
# `aggregates.<context>.<aggregate>.commands.<command>.guards.not_removed.error`.
|
|
122
|
+
# The `:remove` command itself is exempt and remains gated only by `:no_change`.
|
|
123
|
+
#
|
|
124
|
+
# The auto-block is order-independent: `removable` may be declared before or after the
|
|
125
|
+
# other commands on the aggregate.
|
|
126
|
+
#
|
|
127
|
+
# `attr_name` must correspond to an attribute readable on the aggregate (the macro
|
|
128
|
+
# auto-defines it as `:datetime` when missing).
|
|
129
|
+
#
|
|
106
130
|
# @param attr_name [Symbol] the attribute name to use for marking removal
|
|
131
|
+
# @param not_removed_guards [Boolean] when true (default), every non-`:remove` command on
|
|
132
|
+
# the aggregate auto-blocks while `attr_name` is set. Pass `false` to disable the
|
|
133
|
+
# auto-block aggregate-wide; individual commands can still opt in by defining their
|
|
134
|
+
# own `guard(:not_removed)`.
|
|
107
135
|
# @yield Block for defining additional guards and other removal configurations
|
|
108
136
|
# @return [void]
|
|
109
137
|
#
|
|
@@ -124,16 +152,30 @@ module Yes
|
|
|
124
152
|
# removable(attr_name: :deleted_at)
|
|
125
153
|
# end
|
|
126
154
|
#
|
|
127
|
-
|
|
155
|
+
# @example Disable the :not_removed auto-block aggregate-wide
|
|
156
|
+
# class UserAggregate < Yes::Core::Aggregate
|
|
157
|
+
# removable(not_removed_guards: false)
|
|
158
|
+
# end
|
|
159
|
+
#
|
|
160
|
+
def removable(attr_name: :removed_at, not_removed_guards: true, &)
|
|
128
161
|
attribute attr_name, :datetime unless attributes.key?(attr_name)
|
|
162
|
+
@removable_config = { attr_name:, not_removed_guards: }
|
|
129
163
|
|
|
130
|
-
command :remove do
|
|
164
|
+
command :remove, skip_default_guards: %i[not_removed] do
|
|
131
165
|
guard(:no_change) { !public_send(attr_name) }
|
|
132
166
|
update_state { method(attr_name).call { Time.current } }
|
|
133
167
|
instance_eval(&) if block_given?
|
|
134
168
|
end
|
|
135
169
|
end
|
|
136
170
|
|
|
171
|
+
# Returns the removable configuration for the aggregate, or nil if {.removable} was
|
|
172
|
+
# never called.
|
|
173
|
+
#
|
|
174
|
+
# @return [Hash{Symbol => Object}, nil] hash with two keys when set:
|
|
175
|
+
# * `:attr_name` [Symbol] — the attribute that marks removal (default `:removed_at`).
|
|
176
|
+
# * `:not_removed_guards` [Boolean] — whether the auto-block is enabled.
|
|
177
|
+
attr_reader :removable_config
|
|
178
|
+
|
|
137
179
|
# Sets the primary context for the aggregate.
|
|
138
180
|
#
|
|
139
181
|
# @param context [String] The primary context to set.
|
|
@@ -239,12 +281,24 @@ module Yes
|
|
|
239
281
|
# @example Define publish command an published attribute
|
|
240
282
|
# command :publish
|
|
241
283
|
#
|
|
242
|
-
|
|
243
|
-
|
|
284
|
+
# @example Skip the auto-injected :not_removed guard for a single command
|
|
285
|
+
# command :restore, skip_default_guards: %i[not_removed] do
|
|
286
|
+
# guard(:no_change) { removed_at.present? }
|
|
287
|
+
# update_state { removed_at { nil } }
|
|
288
|
+
# end
|
|
289
|
+
#
|
|
290
|
+
# All overloads accept a `skip_default_guards:` keyword argument carrying an array of
|
|
291
|
+
# default-guard symbols (currently only `:not_removed` — see {.removable}) that should
|
|
292
|
+
# not be auto-applied to the command. Defaults to `[]`.
|
|
293
|
+
#
|
|
294
|
+
def command(*args, **kwargs, &)
|
|
295
|
+
skip_default_guards = kwargs.delete(:skip_default_guards) || []
|
|
296
|
+
base_case = Dsl::CommandShortcutExpander.base_case?(*args, **kwargs, &)
|
|
297
|
+
return handle_command_shortcut(*args, skip_default_guards:, **kwargs, &) unless base_case
|
|
244
298
|
|
|
245
299
|
name = args.first
|
|
246
300
|
@commands ||= {}
|
|
247
|
-
command_data = Dsl::CommandData.new(name, self, { context:, aggregate: })
|
|
301
|
+
command_data = Dsl::CommandData.new(name, self, { context:, aggregate:, skip_default_guards: })
|
|
248
302
|
@commands[name] = command_data
|
|
249
303
|
|
|
250
304
|
Dsl::CommandDefiner.new(command_data).call(&)
|
|
@@ -295,8 +349,8 @@ module Yes
|
|
|
295
349
|
#
|
|
296
350
|
# @return [void]
|
|
297
351
|
#
|
|
298
|
-
def handle_command_shortcut(
|
|
299
|
-
expanded = Dsl::CommandShortcutExpander.new(
|
|
352
|
+
def handle_command_shortcut(*, skip_default_guards: [], **, &)
|
|
353
|
+
expanded = Dsl::CommandShortcutExpander.new(*, **, &).call
|
|
300
354
|
|
|
301
355
|
expanded.attributes.each do |specification|
|
|
302
356
|
next if attributes.key?(specification.name)
|
|
@@ -305,7 +359,7 @@ module Yes
|
|
|
305
359
|
end
|
|
306
360
|
|
|
307
361
|
expanded.commands.each do |specification|
|
|
308
|
-
command(specification.name, &specification.block)
|
|
362
|
+
command(specification.name, skip_default_guards:, &specification.block)
|
|
309
363
|
end
|
|
310
364
|
end
|
|
311
365
|
end
|
|
@@ -52,6 +52,7 @@ module Yes
|
|
|
52
52
|
# @raise [InvalidTransition] When a guard fails with an invalid transition
|
|
53
53
|
# @raise [NoChangeTransition] When a guard fails with a no change transition
|
|
54
54
|
def call
|
|
55
|
+
check_not_removed!
|
|
55
56
|
self.class.guards.each do |name, guard_data|
|
|
56
57
|
evaluate_guard(name, error_extra: guard_data[:error_extra], block: guard_data[:block])
|
|
57
58
|
end
|
|
@@ -64,6 +65,28 @@ module Yes
|
|
|
64
65
|
|
|
65
66
|
attr_reader :raw_payload, :raw_metadata, :payload, :aggregate, :aggregate_tracker, :command_name
|
|
66
67
|
|
|
68
|
+
# Pre-check that fires before any registered guard. Blocks every command on a removable
|
|
69
|
+
# aggregate once `removed_at` (or the configured attr) has been set, except where the
|
|
70
|
+
# command opts out via `skip_default_guards: %i[not_removed]`.
|
|
71
|
+
#
|
|
72
|
+
# The check runs ahead of the user-defined guards (including the auto-injected `:no_change`)
|
|
73
|
+
# so post-remove mutations consistently surface "X has been removed" instead of misleading
|
|
74
|
+
# downstream errors like "X does not change".
|
|
75
|
+
#
|
|
76
|
+
# @return [void]
|
|
77
|
+
# @raise [InvalidTransition] When the aggregate has been removed and the command is not opted out.
|
|
78
|
+
def check_not_removed!
|
|
79
|
+
config = aggregate.class.respond_to?(:removable_config) ? aggregate.class.removable_config : nil
|
|
80
|
+
return unless config && config[:not_removed_guards]
|
|
81
|
+
|
|
82
|
+
command_data = aggregate.class.commands[command_name]
|
|
83
|
+
return if command_data&.skip_default_guards&.include?(:not_removed)
|
|
84
|
+
|
|
85
|
+
return if aggregate.public_send(config[:attr_name]).blank?
|
|
86
|
+
|
|
87
|
+
raise InvalidTransition, error_message(:not_removed)
|
|
88
|
+
end
|
|
89
|
+
|
|
67
90
|
# Evaluates a single guard and raises appropriate error if it fails
|
|
68
91
|
#
|
|
69
92
|
# @param name [Symbol] The name of the guard
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yes
|
|
4
|
+
module Core
|
|
5
|
+
module Middlewares
|
|
6
|
+
class << self
|
|
7
|
+
# Returns middleware keys excluding the specified one.
|
|
8
|
+
#
|
|
9
|
+
# @param middleware_name [Symbol] the middleware key to exclude
|
|
10
|
+
# @return [Array<Symbol>] remaining middleware keys
|
|
11
|
+
def without(middleware_name)
|
|
12
|
+
PgEventstore.config.middlewares.except(middleware_name).keys
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yes
|
|
4
|
+
module Core
|
|
5
|
+
module TestSupport
|
|
6
|
+
module Aggregate
|
|
7
|
+
# DSL for writing concise aggregate command specs.
|
|
8
|
+
#
|
|
9
|
+
# Provides `command`, `success`, `invalid`, `no_change`, and `setup` methods
|
|
10
|
+
# that generate RSpec describe/context blocks with appropriate shared examples.
|
|
11
|
+
#
|
|
12
|
+
# @example
|
|
13
|
+
# RSpec.describe MyContext::MyAggregate::Aggregate, type: :aggregate do
|
|
14
|
+
# command 'do_something' do
|
|
15
|
+
# let(:command_data) { { name: 'test' } }
|
|
16
|
+
# let(:success_attributes) { { name: 'test' } }
|
|
17
|
+
#
|
|
18
|
+
# success
|
|
19
|
+
# invalid 'when precondition not met'
|
|
20
|
+
# no_change
|
|
21
|
+
# end
|
|
22
|
+
# end
|
|
23
|
+
module CommandTestDsl
|
|
24
|
+
# Defines a test block for a command
|
|
25
|
+
#
|
|
26
|
+
# @param command_name [String, Symbol] the name of the command to test
|
|
27
|
+
# @param options [Array<Hash>] additional options (e.g., `draft: true`, VCR cassettes)
|
|
28
|
+
# @yield block for configuring test cases with success/invalid/no_change
|
|
29
|
+
def command(command_name, *options, &block)
|
|
30
|
+
describe command_name.to_s, *options do
|
|
31
|
+
let(:draft) { options.first&.dig(:draft) }
|
|
32
|
+
|
|
33
|
+
let(:aggregate) { described_class.new(draft:) } unless method_defined?(:aggregate)
|
|
34
|
+
|
|
35
|
+
subject { aggregate.public_send(command, command_data, guards: !draft) }
|
|
36
|
+
|
|
37
|
+
let(:command) { command_name.to_sym }
|
|
38
|
+
let(:aggregate_class) { aggregate.class }
|
|
39
|
+
let(:command_data_with_id) do
|
|
40
|
+
{ "#{aggregate_class.aggregate.underscore}_id" => aggregate.id }.merge(command_data)
|
|
41
|
+
end
|
|
42
|
+
let(:command_data) { {} }
|
|
43
|
+
let(:expected_event_type) do
|
|
44
|
+
"#{aggregate_class.context}::#{aggregate_class.aggregate}" \
|
|
45
|
+
"#{'Draft' if draft}#{aggregate_class.commands[command].event_name.to_s.classify}"
|
|
46
|
+
end
|
|
47
|
+
let(:expected_event_data) { command_data_with_id }
|
|
48
|
+
let(:expected_event_metadata) { nil }
|
|
49
|
+
let(:success_attributes) { command_data.without(:locale) } unless method_defined?(:success_attributes)
|
|
50
|
+
|
|
51
|
+
class_eval(&block) if block_given?
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Defines a test case for a successful command execution
|
|
56
|
+
#
|
|
57
|
+
# @param description [String] optional description
|
|
58
|
+
# @param options [Hash] additional options (e.g., VCR cassettes)
|
|
59
|
+
# @yield optional block for additional setup or custom assertions
|
|
60
|
+
def success(description = 'when successfully executing command', options = {}, &block)
|
|
61
|
+
context description, options do
|
|
62
|
+
instance_eval(&block) if block_given?
|
|
63
|
+
|
|
64
|
+
it_behaves_like 'successful command'
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Defines a test case for a command that causes no state change
|
|
69
|
+
#
|
|
70
|
+
# @param description [String] optional description
|
|
71
|
+
# @param options [Hash] additional options
|
|
72
|
+
# @yield optional block for additional setup
|
|
73
|
+
def no_change(description = 'when command causes no change', options = {}, &block)
|
|
74
|
+
context description.to_s, options do
|
|
75
|
+
instance_eval(&block) if block_given?
|
|
76
|
+
|
|
77
|
+
before { aggregate.public_send(command, command_data) }
|
|
78
|
+
|
|
79
|
+
it_behaves_like 'no change transition'
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Defines a test case for an invalid transition
|
|
84
|
+
#
|
|
85
|
+
# @param description [String] description of the invalid scenario
|
|
86
|
+
# @param options [Hash] additional options
|
|
87
|
+
# @yield optional block for additional setup
|
|
88
|
+
def invalid(description, options = {}, &block)
|
|
89
|
+
context "when #{description}", options do
|
|
90
|
+
instance_eval(&block) if block_given?
|
|
91
|
+
|
|
92
|
+
it_behaves_like 'invalid transition'
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Alias for `before` — used for readable aggregate setup within command blocks
|
|
97
|
+
#
|
|
98
|
+
# @yield block for setup actions
|
|
99
|
+
def setup(&)
|
|
100
|
+
before(&)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
if defined?(RSpec)
|
|
109
|
+
RSpec.configure do |config|
|
|
110
|
+
config.extend Yes::Core::TestSupport::Aggregate::CommandTestDsl, type: :aggregate
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RSpec::Matchers.define :have_authorizer do
|
|
4
|
+
match do |actual|
|
|
5
|
+
actual&.authorizer_class&.< Yes::Core::Authorization::CommandAuthorizer
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
description do
|
|
9
|
+
'have an authorizer'
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
RSpec::Matchers.define :have_read_model_class do |read_model_class|
|
|
14
|
+
match do |aggregate|
|
|
15
|
+
aggregate.read_model_class == read_model_class
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
failure_message do |aggregate|
|
|
19
|
+
"expected #{aggregate} to have read model class #{read_model_class}" \
|
|
20
|
+
"\n actual read model class is #{aggregate.read_model_class}"
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
RSpec::Matchers.define :have_cerbos_authorizer do
|
|
25
|
+
match do |aggregate|
|
|
26
|
+
next false unless aggregate.authorizer_class&.< Yes::Core::Authorization::CommandCerbosAuthorizer
|
|
27
|
+
|
|
28
|
+
next false if @read_model_class && aggregate.authorizer_options&.read_model_class != @read_model_class
|
|
29
|
+
|
|
30
|
+
next false if @resource_name && aggregate.authorizer_options&.resource_name != @resource_name
|
|
31
|
+
|
|
32
|
+
true
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
chain :with_read_model_class do |read_model_class|
|
|
36
|
+
@read_model_class = read_model_class
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
chain :with_resource_name do |resource_name|
|
|
40
|
+
@resource_name = resource_name
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
description do
|
|
44
|
+
msg = 'have a Cerbos authorizer'
|
|
45
|
+
msg += " with read model class #{@read_model_class}" if @read_model_class
|
|
46
|
+
msg += " with resource name #{@resource_name}" if @resource_name
|
|
47
|
+
msg
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
failure_message do |aggregate|
|
|
51
|
+
msg = "expected #{aggregate} to have a Cerbos authorizer"
|
|
52
|
+
msg += "\n with read model class #{@read_model_class}" if @read_model_class
|
|
53
|
+
msg += "\n with resource name #{@resource_name}" if @resource_name
|
|
54
|
+
msg += "\n actual read model class is #{aggregate.authorizer_options&.read_model_class}" if @read_model_class
|
|
55
|
+
msg += "\n actual resource name is #{aggregate.authorizer_options&.resource_name}" if @resource_name
|
|
56
|
+
msg
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
RSpec::Matchers.define :have_parent do |parent_name|
|
|
61
|
+
match do |aggregate|
|
|
62
|
+
@parent = aggregate.parent_aggregates.with_indifferent_access[parent_name]
|
|
63
|
+
|
|
64
|
+
return false unless @parent
|
|
65
|
+
|
|
66
|
+
return true unless @context
|
|
67
|
+
|
|
68
|
+
@parent[:context] == @context
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
chain :with_context do |context|
|
|
72
|
+
@context = context
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
failure_message do |aggregate|
|
|
76
|
+
msg = "expected #{aggregate} to have parent #{parent_name}"
|
|
77
|
+
msg += "\n with context #{@context}" if @context
|
|
78
|
+
msg += "\n actual context is #{@parent[:context].presence || aggregate.context}" if @parent && @context
|
|
79
|
+
msg
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RSpec.shared_context 'with given events' do
|
|
4
|
+
before do
|
|
5
|
+
given_events.each do |event_data|
|
|
6
|
+
event = event_instance(event_data)
|
|
7
|
+
stream = event_stream(event_data)
|
|
8
|
+
append_event(stream, event)
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
RSpec.shared_examples 'successful command' do
|
|
14
|
+
let(:event) { aggregate.events.map(&:last).last }
|
|
15
|
+
let(:expected_event_metadata) { nil }
|
|
16
|
+
|
|
17
|
+
it 'correctly changes the aggregate state' do
|
|
18
|
+
if success_attributes.any?
|
|
19
|
+
expect { subject }.to change {
|
|
20
|
+
aggregate.read_model.attributes.to_h.symbolize_keys.slice(*success_attributes.keys)
|
|
21
|
+
}.to(success_attributes)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
it 'publishes expected event' do
|
|
26
|
+
subject
|
|
27
|
+
aggregate_failures do
|
|
28
|
+
expect(event.type).to eq(expected_event_type)
|
|
29
|
+
expect(event.data).to eq(expected_event_data.deep_stringify_keys)
|
|
30
|
+
expect(event.metadata).to include(expected_event_metadata.deep_stringify_keys) if expected_event_metadata
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
RSpec.shared_examples 'invalid transition' do
|
|
36
|
+
it 'raises InvalidTransition error' do
|
|
37
|
+
expect(subject.error).to be_a(Yes::Core::CommandHandling::GuardEvaluator::InvalidTransition)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
it 'does not change the aggregate state' do
|
|
41
|
+
success_attributes.each_key do |attribute|
|
|
42
|
+
expect { subject }.not_to(change { aggregate.public_send(attribute) })
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
it 'does not publish event' do
|
|
47
|
+
expect { subject }.not_to(
|
|
48
|
+
change do
|
|
49
|
+
aggregate.events.map(&:flatten).flatten.count
|
|
50
|
+
rescue PgEventstore::StreamNotFoundError
|
|
51
|
+
nil
|
|
52
|
+
end
|
|
53
|
+
)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
RSpec.shared_examples 'no change transition' do
|
|
58
|
+
it 'raises NoChangeTransition error' do
|
|
59
|
+
expect(subject.error).to be_a(Yes::Core::CommandHandling::GuardEvaluator::NoChangeTransition)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
it 'does not change the aggregate state' do
|
|
63
|
+
success_attributes.each_key do |attribute|
|
|
64
|
+
expect { subject }.not_to(change { aggregate.public_send(attribute) })
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
it 'does not publish event' do
|
|
69
|
+
expect { subject }.not_to(
|
|
70
|
+
change do
|
|
71
|
+
aggregate.events.map(&:flatten).flatten.count
|
|
72
|
+
rescue PgEventstore::StreamNotFoundError
|
|
73
|
+
nil
|
|
74
|
+
end
|
|
75
|
+
)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -4,16 +4,33 @@ module Yes
|
|
|
4
4
|
module Core
|
|
5
5
|
module TestSupport
|
|
6
6
|
# Helpers for working with PgEventstore events in tests.
|
|
7
|
+
#
|
|
8
|
+
# @example Include in RSpec
|
|
9
|
+
# RSpec.configure do |config|
|
|
10
|
+
# config.include Yes::Core::TestSupport::EventHelpers
|
|
11
|
+
# end
|
|
7
12
|
module EventHelpers
|
|
13
|
+
# Appends an event to a stream
|
|
14
|
+
#
|
|
15
|
+
# @param stream [PgEventstore::Stream]
|
|
16
|
+
# @param event [Yes::Core::Event]
|
|
17
|
+
# @return [void]
|
|
18
|
+
def append_event(stream, event)
|
|
19
|
+
PgEventstore.client.append_to_stream(stream, event)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Appends an event to a stream and reloads it
|
|
23
|
+
#
|
|
8
24
|
# @param stream [PgEventstore::Stream]
|
|
9
25
|
# @param event [Yes::Core::Event]
|
|
10
26
|
# @return [Yes::Core::Event]
|
|
11
27
|
def append_and_reload_event(stream, event)
|
|
12
|
-
|
|
28
|
+
append_event(stream, event)
|
|
13
29
|
PgEventstore.client.read(stream, options: { max_count: 1, direction: :desc }).first
|
|
14
30
|
end
|
|
15
31
|
|
|
16
32
|
# Reads eventstore and returns events from the stream or an empty array if stream does not exist
|
|
33
|
+
#
|
|
17
34
|
# @param stream [PgEventstore::Stream]
|
|
18
35
|
# @return [Array<Yes::Core::Event>]
|
|
19
36
|
def safe_read(stream)
|
|
@@ -21,6 +38,61 @@ module Yes
|
|
|
21
38
|
rescue PgEventstore::StreamNotFoundError
|
|
22
39
|
[]
|
|
23
40
|
end
|
|
41
|
+
|
|
42
|
+
alias read_events safe_read
|
|
43
|
+
|
|
44
|
+
# Creates events from a block and appends them to the eventstore
|
|
45
|
+
#
|
|
46
|
+
# @yield block that returns an array of event attribute hashes
|
|
47
|
+
# @yieldreturn [Array<Hash>] each hash should have :context, :aggregate, :event, :data keys
|
|
48
|
+
# @return [void]
|
|
49
|
+
#
|
|
50
|
+
# @example
|
|
51
|
+
# given_events do
|
|
52
|
+
# [{ context: 'MyContext', aggregate: 'MyAggregate', event: 'Created', data: { id: '123' } }]
|
|
53
|
+
# end
|
|
54
|
+
def given_events(&)
|
|
55
|
+
events_data = yield
|
|
56
|
+
events_data.each do |event_data|
|
|
57
|
+
event = event_instance(event_data)
|
|
58
|
+
stream = event_stream(event_data)
|
|
59
|
+
append_event(stream, event)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Builds a Yes::Core::Event from a hash of event attributes
|
|
64
|
+
#
|
|
65
|
+
# @param event_attrs [Hash] event attributes with :context, :aggregate, :event, :data keys
|
|
66
|
+
# @return [Yes::Core::Event]
|
|
67
|
+
def event_instance(event_attrs)
|
|
68
|
+
Yes::Core::Event.new(
|
|
69
|
+
type: event_type(event_attrs),
|
|
70
|
+
data: (event_attrs[:data] || {}).with_indifferent_access
|
|
71
|
+
)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Builds a PgEventstore::Stream from event attributes
|
|
75
|
+
#
|
|
76
|
+
# @param event_attrs [Hash] event attributes with :context, :aggregate, :data keys
|
|
77
|
+
# @return [PgEventstore::Stream]
|
|
78
|
+
def event_stream(event_attrs)
|
|
79
|
+
return event_attrs[:stream] if event_attrs[:stream]
|
|
80
|
+
|
|
81
|
+
PgEventstore::Stream.new(
|
|
82
|
+
context: event_attrs[:context],
|
|
83
|
+
stream_name: event_attrs[:aggregate],
|
|
84
|
+
stream_id: event_attrs[:data].first.last
|
|
85
|
+
)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Constructs an event type string from event attributes
|
|
89
|
+
#
|
|
90
|
+
# @param event_attrs [Hash] event attributes with :context, :aggregate/:subject, :event keys
|
|
91
|
+
# @return [String]
|
|
92
|
+
def event_type(event_attrs)
|
|
93
|
+
aggregate_or_subject = event_attrs[:aggregate] || event_attrs[:subject]
|
|
94
|
+
"#{event_attrs[:context]}::#{aggregate_or_subject}#{event_attrs[:event]}"
|
|
95
|
+
end
|
|
24
96
|
end
|
|
25
97
|
end
|
|
26
98
|
end
|
|
@@ -3,3 +3,6 @@
|
|
|
3
3
|
require_relative 'test_support/event_helpers'
|
|
4
4
|
require_relative 'test_support/jwt_helpers'
|
|
5
5
|
require_relative 'test_support/test_helper'
|
|
6
|
+
require_relative 'test_support/aggregate/command_test_dsl'
|
|
7
|
+
require_relative 'test_support/aggregate/shared_examples'
|
|
8
|
+
require_relative 'test_support/aggregate/matchers'
|
|
@@ -3,14 +3,20 @@
|
|
|
3
3
|
module Yes
|
|
4
4
|
module Core
|
|
5
5
|
module Utils
|
|
6
|
-
# Provides convenient shortcuts for accessing aggregate classes in Rails console
|
|
7
|
-
# @example
|
|
8
|
-
# # Instead of: ApprenticeshipPresentation::
|
|
9
|
-
# # Use: AP::
|
|
6
|
+
# Provides convenient shortcuts for accessing aggregate classes in Rails console.
|
|
7
|
+
# @example Multi-capital subjects use capitals-only abbreviations
|
|
8
|
+
# # Instead of: ApprenticeshipPresentation::ContactInfo::Aggregate.new(id)
|
|
9
|
+
# # Use: AP::CI.new(id)
|
|
10
|
+
# @example Single-capital subjects keep the full name
|
|
11
|
+
# # Instead of: TaskFlow::Board::Aggregate.new(id)
|
|
12
|
+
# # Use: TF::Board.new(id)
|
|
10
13
|
class AggregateShortcuts
|
|
11
14
|
class << self
|
|
12
|
-
# Load aggregate shortcuts in Rails console
|
|
13
|
-
# Creates
|
|
15
|
+
# Load aggregate shortcuts in Rails console.
|
|
16
|
+
# Creates fresh shortcut modules (e.g. TF) and assigns aggregate classes
|
|
17
|
+
# as constants on them. Shortcut modules are NOT aliases of the real
|
|
18
|
+
# context modules, so shortcut constants cannot collide with the
|
|
19
|
+
# aggregates' own namespace modules.
|
|
14
20
|
def load!
|
|
15
21
|
return unless Yes::Core.configuration.aggregate_shortcuts
|
|
16
22
|
|
|
@@ -32,26 +38,35 @@ module Yes
|
|
|
32
38
|
results
|
|
33
39
|
end
|
|
34
40
|
|
|
35
|
-
# Display shortcuts in a formatted table
|
|
41
|
+
# Display shortcuts in a formatted table.
|
|
42
|
+
#
|
|
43
|
+
# Writes directly to STDOUT (via Kernel#puts) rather than the Rails logger
|
|
44
|
+
# so the output is readable in any environment — Rails consoles in
|
|
45
|
+
# production typically configure structured / JSON loggers that would
|
|
46
|
+
# otherwise wrap each line in a JSON envelope.
|
|
47
|
+
#
|
|
36
48
|
# @param filter [String, nil] Optional filter
|
|
49
|
+
# rubocop:disable Rails/Output
|
|
37
50
|
def display(filter = nil)
|
|
38
51
|
shortcuts = list(filter)
|
|
39
52
|
|
|
40
53
|
if shortcuts.empty?
|
|
41
|
-
|
|
54
|
+
puts "No shortcuts found#{" for '#{filter}'" if filter}."
|
|
42
55
|
return
|
|
43
56
|
end
|
|
44
57
|
|
|
45
58
|
max_shortcut_length = shortcuts.keys.map(&:length).max
|
|
59
|
+
separator = '=' * (max_shortcut_length + 70)
|
|
46
60
|
|
|
47
|
-
|
|
48
|
-
|
|
61
|
+
puts "\nAvailable Aggregate Shortcuts:"
|
|
62
|
+
puts separator
|
|
49
63
|
shortcuts.sort.each do |shortcut, full_path|
|
|
50
|
-
|
|
64
|
+
puts "#{shortcut.ljust(max_shortcut_length)} → #{full_path}"
|
|
51
65
|
end
|
|
52
|
-
|
|
53
|
-
|
|
66
|
+
puts separator
|
|
67
|
+
puts "\nUsage: #{shortcuts.keys.first}.new(id)"
|
|
54
68
|
end
|
|
69
|
+
# rubocop:enable Rails/Output
|
|
55
70
|
|
|
56
71
|
private
|
|
57
72
|
|
|
@@ -102,19 +117,22 @@ module Yes
|
|
|
102
117
|
context_abbr = abbreviate_context(agg[:context])
|
|
103
118
|
subject_abbr = abbreviate_subject(agg[:subject])
|
|
104
119
|
|
|
105
|
-
#
|
|
120
|
+
# Build (or reuse) a fresh container module for the context shortcut.
|
|
121
|
+
# We deliberately do NOT alias the real context module: doing so would
|
|
122
|
+
# mean shortcut constants (e.g. TF::Board) collide with the real
|
|
123
|
+
# namespace modules of the aggregates themselves (TaskFlow::Board).
|
|
106
124
|
unless context_modules[context_abbr]
|
|
107
125
|
if Object.const_defined?(context_abbr)
|
|
108
126
|
Rails.logger.warn("Shortcut conflict: #{context_abbr} already defined, skipping #{agg[:context]}")
|
|
109
127
|
next
|
|
110
128
|
end
|
|
111
129
|
|
|
112
|
-
|
|
113
|
-
Object.const_set(context_abbr,
|
|
114
|
-
context_modules[context_abbr] =
|
|
130
|
+
shortcut_module = Module.new
|
|
131
|
+
Object.const_set(context_abbr, shortcut_module)
|
|
132
|
+
context_modules[context_abbr] = shortcut_module
|
|
115
133
|
end
|
|
116
134
|
|
|
117
|
-
# Create subject constant within
|
|
135
|
+
# Create subject constant within the shortcut container.
|
|
118
136
|
context_mod = context_modules[context_abbr]
|
|
119
137
|
if context_mod.const_defined?(subject_abbr)
|
|
120
138
|
Rails.logger.warn("Shortcut conflict: #{context_abbr}::#{subject_abbr} already defined")
|
|
@@ -140,12 +158,15 @@ module Yes
|
|
|
140
158
|
def abbreviate_subject(subject)
|
|
141
159
|
return @subject_overrides[subject] if @subject_overrides[subject]
|
|
142
160
|
|
|
143
|
-
#
|
|
161
|
+
# Multi-capital CamelCase names get a capitals-only abbreviation
|
|
162
|
+
# (ContactInfo → CI). Single-capital names (Task, Board, Location)
|
|
163
|
+
# use the full subject name to avoid awkward truncations like
|
|
164
|
+
# "Boar" or shortcut collisions when the truncation matches the
|
|
165
|
+
# subject's own namespace module (e.g. TaskFlow::Task).
|
|
144
166
|
capitals = subject.scan(/[A-Z]/).join
|
|
145
167
|
return capitals if capitals.length > 1
|
|
146
168
|
|
|
147
|
-
|
|
148
|
-
subject[0..3]
|
|
169
|
+
subject
|
|
149
170
|
end
|
|
150
171
|
|
|
151
172
|
def define_helper_method
|
|
@@ -68,7 +68,7 @@ module Yes
|
|
|
68
68
|
def build_event(command_name:, payload:, metadata: {})
|
|
69
69
|
event_class = Yes::Core.configuration.event_classes_for_command(context, aggregate, command_name).first
|
|
70
70
|
event_class.new(
|
|
71
|
-
type: "#{context}::#{aggregate_name_with_draft_suffix(aggregate, metadata)}#{event_class.name.demodulize}",
|
|
71
|
+
type: "#{context}::#{aggregate_name_with_draft_suffix(aggregate, metadata, context:)}#{event_class.name.demodulize}",
|
|
72
72
|
data: payload,
|
|
73
73
|
metadata:
|
|
74
74
|
)
|
|
@@ -83,7 +83,7 @@ module Yes
|
|
|
83
83
|
def build_stream(context: @context, name: @aggregate, id: @aggregate_id, metadata: {})
|
|
84
84
|
PgEventstore::Stream.new(
|
|
85
85
|
context:,
|
|
86
|
-
stream_name: aggregate_name_with_draft_suffix(name, metadata),
|
|
86
|
+
stream_name: aggregate_name_with_draft_suffix(name, metadata, context:),
|
|
87
87
|
stream_id: id
|
|
88
88
|
)
|
|
89
89
|
end
|
|
@@ -209,16 +209,33 @@ module Yes
|
|
|
209
209
|
payload.merge(locale: I18n.locale.to_s)
|
|
210
210
|
end
|
|
211
211
|
|
|
212
|
-
# Builds the aggregate name with the draft suffix
|
|
212
|
+
# Builds the aggregate name with the draft suffix.
|
|
213
|
+
#
|
|
214
|
+
# When the aggregate class is configured with `draftable changes_read_model:` (explicitly),
|
|
215
|
+
# the camelized changes_read_model is used as the stream / event-type prefix so the DSL
|
|
216
|
+
# config decides where draft events land. This lets an aggregate share an edit-template
|
|
217
|
+
# stream with a sibling (e.g. `Recruiter` writes to `UserEditTemplate` via
|
|
218
|
+
# `changes_read_model: :user_edit_template`).
|
|
219
|
+
#
|
|
220
|
+
# For non-draftable aggregates, and for draftable aggregates that rely on the default
|
|
221
|
+
# `<read_model>_change` changes_read_model name, the legacy hard-coded `<Aggregate>Draft`
|
|
222
|
+
# / `<Aggregate>EditTemplate` suffix is used.
|
|
213
223
|
#
|
|
214
224
|
# @param aggregate_name [String] The name of the aggregate
|
|
215
225
|
# @param metadata [Hash] The command metadata
|
|
216
|
-
# @
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
return
|
|
220
|
-
|
|
221
|
-
aggregate_name
|
|
226
|
+
# @param context [String] The aggregate's context (defaults to @context)
|
|
227
|
+
# @return [String] The stream / event-type name component
|
|
228
|
+
def aggregate_name_with_draft_suffix(aggregate_name, metadata = {}, context: @context)
|
|
229
|
+
return aggregate_name unless metadata&.dig(:draft) || metadata&.dig(:edit_template_command)
|
|
230
|
+
|
|
231
|
+
klass = "#{context}::#{aggregate_name}::Aggregate".safe_constantize
|
|
232
|
+
if klass.respond_to?(:_changes_read_model_explicit) && klass._changes_read_model_explicit
|
|
233
|
+
klass.changes_read_model_name.camelize
|
|
234
|
+
elsif metadata&.dig(:edit_template_command)
|
|
235
|
+
"#{aggregate_name}EditTemplate"
|
|
236
|
+
else
|
|
237
|
+
"#{aggregate_name}Draft"
|
|
238
|
+
end
|
|
222
239
|
end
|
|
223
240
|
end
|
|
224
241
|
end
|
data/lib/yes/core/version.rb
CHANGED
data/lib/yes/core.rb
CHANGED
|
@@ -19,6 +19,7 @@ module Yes
|
|
|
19
19
|
loader.push_dir(File.expand_path('..', __dir__))
|
|
20
20
|
loader.ignore("#{__dir__}/core/version.rb")
|
|
21
21
|
loader.ignore("#{__dir__}/core/test_support")
|
|
22
|
+
loader.ignore("#{__dir__}/core/test_support.rb")
|
|
22
23
|
loader.collapse("#{__dir__}/core/models")
|
|
23
24
|
loader.setup
|
|
24
25
|
loader
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: yes-core
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Nico Ritsche
|
|
@@ -259,6 +259,7 @@ files:
|
|
|
259
259
|
- lib/yes/core/generators/read_models/templates/migration.rb.erb
|
|
260
260
|
- lib/yes/core/generators/read_models/update_generator.rb
|
|
261
261
|
- lib/yes/core/jobs/read_model_recovery_job.rb
|
|
262
|
+
- lib/yes/core/middlewares.rb
|
|
262
263
|
- lib/yes/core/middlewares/encryptor.rb
|
|
263
264
|
- lib/yes/core/middlewares/timestamp.rb
|
|
264
265
|
- lib/yes/core/middlewares/with_indifferent_access.rb
|
|
@@ -281,6 +282,9 @@ files:
|
|
|
281
282
|
- lib/yes/core/serializer.rb
|
|
282
283
|
- lib/yes/core/subscriptions.rb
|
|
283
284
|
- lib/yes/core/test_support.rb
|
|
285
|
+
- lib/yes/core/test_support/aggregate/command_test_dsl.rb
|
|
286
|
+
- lib/yes/core/test_support/aggregate/matchers.rb
|
|
287
|
+
- lib/yes/core/test_support/aggregate/shared_examples.rb
|
|
284
288
|
- lib/yes/core/test_support/event_helpers.rb
|
|
285
289
|
- lib/yes/core/test_support/jwt_helpers.rb
|
|
286
290
|
- lib/yes/core/test_support/subscriptions_helper.rb
|