yes-core 1.1.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 +4 -0
- 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/utils/aggregate_shortcuts.rb +16 -7
- data/lib/yes/core/version.rb +1 -1
- metadata +1 -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
|
@@ -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
|
|
@@ -38,26 +38,35 @@ module Yes
|
|
|
38
38
|
results
|
|
39
39
|
end
|
|
40
40
|
|
|
41
|
-
# 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
|
+
#
|
|
42
48
|
# @param filter [String, nil] Optional filter
|
|
49
|
+
# rubocop:disable Rails/Output
|
|
43
50
|
def display(filter = nil)
|
|
44
51
|
shortcuts = list(filter)
|
|
45
52
|
|
|
46
53
|
if shortcuts.empty?
|
|
47
|
-
|
|
54
|
+
puts "No shortcuts found#{" for '#{filter}'" if filter}."
|
|
48
55
|
return
|
|
49
56
|
end
|
|
50
57
|
|
|
51
58
|
max_shortcut_length = shortcuts.keys.map(&:length).max
|
|
59
|
+
separator = '=' * (max_shortcut_length + 70)
|
|
52
60
|
|
|
53
|
-
|
|
54
|
-
|
|
61
|
+
puts "\nAvailable Aggregate Shortcuts:"
|
|
62
|
+
puts separator
|
|
55
63
|
shortcuts.sort.each do |shortcut, full_path|
|
|
56
|
-
|
|
64
|
+
puts "#{shortcut.ljust(max_shortcut_length)} → #{full_path}"
|
|
57
65
|
end
|
|
58
|
-
|
|
59
|
-
|
|
66
|
+
puts separator
|
|
67
|
+
puts "\nUsage: #{shortcuts.keys.first}.new(id)"
|
|
60
68
|
end
|
|
69
|
+
# rubocop:enable Rails/Output
|
|
61
70
|
|
|
62
71
|
private
|
|
63
72
|
|
data/lib/yes/core/version.rb
CHANGED