yes-core 1.1.0 → 1.3.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 (27) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +8 -0
  3. data/lib/yes/core/aggregate/dsl/class_name_convention.rb +8 -0
  4. data/lib/yes/core/aggregate/dsl/class_resolvers/command_group/base.rb +34 -0
  5. data/lib/yes/core/aggregate/dsl/class_resolvers/command_group/command.rb +43 -0
  6. data/lib/yes/core/aggregate/dsl/class_resolvers/command_group/guard_evaluator.rb +35 -0
  7. data/lib/yes/core/aggregate/dsl/command_data.rb +5 -1
  8. data/lib/yes/core/aggregate/dsl/command_group_data.rb +45 -0
  9. data/lib/yes/core/aggregate/dsl/command_group_definer.rb +100 -0
  10. data/lib/yes/core/aggregate/dsl/method_definers/command_group/base.rb +29 -0
  11. data/lib/yes/core/aggregate/dsl/method_definers/command_group/can_command_group.rb +41 -0
  12. data/lib/yes/core/aggregate/dsl/method_definers/command_group/command_group.rb +40 -0
  13. data/lib/yes/core/aggregate.rb +113 -9
  14. data/lib/yes/core/command_handling/command_group_executor.rb +236 -0
  15. data/lib/yes/core/command_handling/command_group_handler.rb +89 -0
  16. data/lib/yes/core/command_handling/guard_evaluator.rb +23 -0
  17. data/lib/yes/core/commands/command_group.rb +147 -0
  18. data/lib/yes/core/commands/command_group_response.rb +66 -0
  19. data/lib/yes/core/commands/group.rb +7 -12
  20. data/lib/yes/core/commands/group_payload_normalizer.rb +45 -0
  21. data/lib/yes/core/configuration.rb +22 -0
  22. data/lib/yes/core/test_support/aggregate/command_test_dsl.rb +77 -2
  23. data/lib/yes/core/test_support/aggregate/shared_examples.rb +45 -0
  24. data/lib/yes/core/utils/aggregate_shortcuts.rb +16 -7
  25. data/lib/yes/core/utils/command_utils.rb +21 -0
  26. data/lib/yes/core/version.rb +1 -1
  27. metadata +14 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b3e28057d0c8b0fd6c0bc4f4692c55609b353258637320027efe1d2bcbb02841
4
- data.tar.gz: fdb462463528fdbecf3df473f5c0f98601a8d301ec7e4d52f98ec0b2aabdc0f3
3
+ metadata.gz: 0a7213daeb56ae07a8921c83b912e83b7f9cd81f6d93d90a4394cbe07b7c9299
4
+ data.tar.gz: 58379a04830d2ba08c0ca4431d4e372ee8cc67f8445621d7143c61f1dc27ab9a
5
5
  SHA512:
6
- metadata.gz: 415e51919c284f2207fbd6ee6dfd9a9427830a6f20ab88986a69b94c14c62b15a75d0fe19360d58088a3d4de8beec953fa78e69c180458874c1d639d677fd9e0
7
- data.tar.gz: 85a8557a897a3c2d02b315bf1e637f2bd6837300bf65b71430c314d9a047b9663cf8d33cfaeb7a681ea2bb5eaaf645a4297e351c3304abd56c8026a03d978792
6
+ metadata.gz: f5ce8a14fb78e7c9bc1b11264e16a17f0cbdc0ef3dcbbd7175114a1c772097b67cb1226f359429a6be83900f26d0e911dd18df838aa9c9ad5a76cab5d999b889
7
+ data.tar.gz: 01de285d8c90f81625bdd1ff721877b87d23edad1949b1eb860ebd4fc0274c459fbbeb3080e6ecb678fa1cd0174b010b4eb4f146ff00d6e4bea4be935685bb08
data/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.3.0] - 2026-05-18
4
+
5
+ - See root CHANGELOG.md for details.
6
+
7
+ ## [1.2.0] - 2026-04-30
8
+
9
+ - See root CHANGELOG.md for details.
10
+
3
11
  ## [1.1.0] - 2026-04-28
4
12
 
5
13
  - See root CHANGELOG.md for details.
@@ -43,6 +43,14 @@ module Yes
43
43
  "#{context}::#{aggregate}::Commands::#{name.to_s.camelize}::GuardEvaluator"
44
44
  end
45
45
 
46
+ def command_group_class_name(name)
47
+ "#{context}::#{aggregate}::CommandGroups::#{name.to_s.camelize}::Command"
48
+ end
49
+
50
+ def command_group_guard_evaluator_class_name(name)
51
+ "#{context}::#{aggregate}::CommandGroups::#{name.to_s.camelize}::GuardEvaluator"
52
+ end
53
+
46
54
  def state_updater_class_name(name)
47
55
  "#{context}::#{aggregate}::Commands::#{name.to_s.camelize}::StateUpdater"
48
56
  end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yes
4
+ module Core
5
+ class Aggregate
6
+ module Dsl
7
+ module ClassResolvers
8
+ module CommandGroup
9
+ # Base class for command_group-related class resolvers.
10
+ #
11
+ # Mirrors {ClassResolvers::Command::Base} but binds to
12
+ # {CommandGroupData} instead of {CommandData}.
13
+ #
14
+ # @abstract Subclass and implement {ClassResolvers::Base#class_type},
15
+ # {ClassResolvers::Base#class_name}, and
16
+ # {ClassResolvers::Base#generate_class}.
17
+ class Base < ClassResolvers::Base
18
+ # @param command_group_data [Yes::Core::Aggregate::Dsl::CommandGroupData]
19
+ def initialize(command_group_data)
20
+ @command_group_data = command_group_data
21
+
22
+ super(command_group_data.context_name, command_group_data.aggregate_name)
23
+ end
24
+
25
+ private
26
+
27
+ attr_reader :command_group_data
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yes
4
+ module Core
5
+ class Aggregate
6
+ module Dsl
7
+ module ClassResolvers
8
+ module CommandGroup
9
+ # Resolves or generates the Command class for an aggregate-DSL
10
+ # command group. The generated class is a {Yes::Core::Commands::CommandGroup}
11
+ # subclass carrying the group's identity (context, aggregate,
12
+ # group_name) and the ordered list of sub-command names.
13
+ class Command < Base
14
+ private
15
+
16
+ def class_type
17
+ :command_group
18
+ end
19
+
20
+ def class_name
21
+ command_group_data.name
22
+ end
23
+
24
+ def generate_class
25
+ group_name = command_group_data.name
26
+ ctx = command_group_data.context_name
27
+ agg = command_group_data.aggregate_name
28
+ sub_commands = command_group_data.sub_command_names.dup
29
+
30
+ Class.new(Yes::Core::Commands::CommandGroup).tap do |klass|
31
+ klass.context = ctx
32
+ klass.aggregate = agg
33
+ klass.group_name = group_name
34
+ klass.sub_command_names = sub_commands
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yes
4
+ module Core
5
+ class Aggregate
6
+ module Dsl
7
+ module ClassResolvers
8
+ module CommandGroup
9
+ # Resolves or generates the GuardEvaluator class for a command_group.
10
+ #
11
+ # Unlike the per-command resolver, no `:no_change` guard is
12
+ # auto-injected — command groups are intended to be lighter on
13
+ # guard checks and rely on whatever set of guards the user
14
+ # declares explicitly in the DSL block.
15
+ class GuardEvaluator < Base
16
+ private
17
+
18
+ def class_type
19
+ :command_group_guard_evaluator
20
+ end
21
+
22
+ def class_name
23
+ command_group_data.name
24
+ end
25
+
26
+ def generate_class
27
+ Class.new(Yes::Core::CommandHandling::GuardEvaluator)
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -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
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yes
4
+ module Core
5
+ class Aggregate
6
+ module Dsl
7
+ # Data object that holds information about a command_group definition
8
+ # in an aggregate.
9
+ #
10
+ # @example
11
+ # CommandGroupData.new(:create_apprenticeship, MyAggregate, context: 'Companies', aggregate: 'Apprenticeship')
12
+ class CommandGroupData
13
+ attr_reader :name, :context_name, :aggregate_name, :aggregate_class
14
+ attr_accessor :sub_command_names, :guard_names
15
+
16
+ # @param name [Symbol] the name of the command group
17
+ # @param aggregate_class [Class] the aggregate class the group belongs to
18
+ # @param options [Hash] additional options
19
+ # @option options [String] :context the context name
20
+ # @option options [String] :aggregate the aggregate name
21
+ def initialize(name, aggregate_class, options = {})
22
+ @name = name
23
+ @aggregate_class = aggregate_class
24
+ @context_name = options[:context]
25
+ @aggregate_name = options[:aggregate]
26
+ @sub_command_names = []
27
+ @guard_names = []
28
+ end
29
+
30
+ # @param name [Symbol] sub-command name to append (preserves order)
31
+ # @return [void]
32
+ def add_sub_command(name)
33
+ @sub_command_names << name
34
+ end
35
+
36
+ # @param name [Symbol] guard name to record on this group
37
+ # @return [void]
38
+ def add_guard(name)
39
+ @guard_names << name
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yes
4
+ module Core
5
+ class Aggregate
6
+ module Dsl
7
+ # Factory class that creates and defines command_groups on aggregates.
8
+ #
9
+ # Mirrors {CommandDefiner}. The DSL evaluator inside accepts two
10
+ # methods: `command :sub_command_name` to push a sub-command symbol,
11
+ # and `guard(name, error_extra: …) { … }` to register a group-level
12
+ # guard on the generated GuardEvaluator class.
13
+ #
14
+ # @example
15
+ # group_data = CommandGroupData.new(:create_apprenticeship, MyAggregate,
16
+ # context: 'Companies', aggregate: 'Apprenticeship')
17
+ # CommandGroupDefiner.new(group_data).call do
18
+ # command :assign_company
19
+ # command :change_name
20
+ #
21
+ # guard(:company_assigned) { company_id.present? }
22
+ # end
23
+ class CommandGroupDefiner
24
+ # Raised when an unknown sub-command name is referenced.
25
+ class UnknownSubCommandError < Yes::Core::Error; end
26
+
27
+ attr_reader :command_group_data
28
+ private :command_group_data
29
+
30
+ # @param command_group_data [CommandGroupData]
31
+ def initialize(command_group_data)
32
+ @command_group_data = command_group_data
33
+ end
34
+
35
+ # Generates and registers all classes/methods for the command group.
36
+ #
37
+ # @yield Block for declaring sub-commands and guards
38
+ # @return [void]
39
+ def call(&block)
40
+ create_and_register_guard_evaluator
41
+ evaluate_dsl_block(&block) if block
42
+ create_and_register_command
43
+ define_aggregate_methods
44
+ end
45
+
46
+ private
47
+
48
+ def create_and_register_guard_evaluator
49
+ @guard_evaluator_class = ClassResolvers::CommandGroup::GuardEvaluator.new(command_group_data).call
50
+ end
51
+
52
+ def create_and_register_command
53
+ ClassResolvers::CommandGroup::Command.new(command_group_data).call
54
+ end
55
+
56
+ def define_aggregate_methods
57
+ MethodDefiners::CommandGroup::CommandGroup.new(command_group_data).call
58
+ MethodDefiners::CommandGroup::CanCommandGroup.new(command_group_data).call
59
+ end
60
+
61
+ def evaluate_dsl_block(&)
62
+ DslEvaluator.new(command_group_data, @guard_evaluator_class).instance_eval(&)
63
+ end
64
+
65
+ # DSL evaluator that backs the `command_group :name do … end` block.
66
+ class DslEvaluator
67
+ attr_reader :command_group_data, :guard_evaluator_class
68
+
69
+ def initialize(command_group_data, guard_evaluator_class)
70
+ @command_group_data = command_group_data
71
+ @guard_evaluator_class = guard_evaluator_class
72
+ end
73
+
74
+ # Declare a sub-command. Order is preserved as execution order.
75
+ #
76
+ # @param name [Symbol] the sub-command name (must match a command
77
+ # declared on the same aggregate)
78
+ # @return [void]
79
+ def command(name)
80
+ command_group_data.add_sub_command(name.to_sym)
81
+ end
82
+
83
+ # Register a group-level guard. Semantics match the per-command
84
+ # `guard` DSL — `:no_change` is recognized as the magic name that
85
+ # raises {NoChangeTransition} on failure.
86
+ #
87
+ # @param name [Symbol] the guard name
88
+ # @param error_extra [Hash, Proc] extra error context
89
+ # @yield Block returning true if the guard passes
90
+ # @return [void]
91
+ def guard(name, error_extra: {}, &)
92
+ command_group_data.add_guard(name)
93
+ guard_evaluator_class.guard(name, error_extra:, &)
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yes
4
+ module Core
5
+ class Aggregate
6
+ module Dsl
7
+ module MethodDefiners
8
+ module CommandGroup
9
+ # Base class for command_group method definers.
10
+ class Base
11
+ def initialize(command_group_data)
12
+ @name = command_group_data.name
13
+ @aggregate_class = command_group_data.aggregate_class
14
+ end
15
+
16
+ def call
17
+ raise NotImplementedError, "#{self.class} must implement #call"
18
+ end
19
+
20
+ private
21
+
22
+ attr_reader :name, :aggregate_class
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yes
4
+ module Core
5
+ class Aggregate
6
+ module Dsl
7
+ module MethodDefiners
8
+ module CommandGroup
9
+ # Defines `aggregate.can_<group_name>?(payload = {})` and the
10
+ # `<group_name>_error` accessor on the aggregate class.
11
+ #
12
+ # Mirrors {MethodDefiners::Command::CanCommand} but resolves the
13
+ # group's GuardEvaluator class instead of a command's.
14
+ class CanCommandGroup < Base
15
+ def call
16
+ can_method = :"can_#{@name}?"
17
+ error_method = :"#{@name}_error"
18
+
19
+ aggregate_class.attr_accessor error_method
20
+ group_name = @name
21
+
22
+ aggregate_class.define_method(can_method) do |payload = {}|
23
+ cmd = command_utilities.build_group_command(group_name, payload)
24
+ guard_evaluator_class = command_utilities.fetch_guard_evaluator_class_for_group(group_name)
25
+
26
+ Yes::Core::CommandHandling::GuardRunner.new(self).call(
27
+ cmd, group_name, guard_evaluator_class, skip_guards: false
28
+ ).present?
29
+ rescue Yes::Core::CommandHandling::GuardEvaluator::InvalidTransition,
30
+ Yes::Core::CommandHandling::GuardEvaluator::NoChangeTransition,
31
+ Yes::Core::Command::Invalid
32
+ false
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yes
4
+ module Core
5
+ class Aggregate
6
+ module Dsl
7
+ module MethodDefiners
8
+ module CommandGroup
9
+ # Defines `aggregate.<group_name>(payload = nil, **options)` on the
10
+ # aggregate class. Mirrors {MethodDefiners::Command::Command} but
11
+ # delegates to {Yes::Core::CommandHandling::CommandGroupHandler}.
12
+ class CommandGroup < Base
13
+ def call
14
+ group_name = @name
15
+
16
+ aggregate_class.define_method(group_name) do |payload = nil, **options|
17
+ payload = payload.clone if payload.is_a?(Hash)
18
+
19
+ guards = options.delete(:guards)
20
+ guards = true if guards.nil?
21
+ metadata = options.delete(:metadata)
22
+
23
+ if payload.nil? && !options.empty?
24
+ payload = options
25
+ elsif payload.nil?
26
+ payload = {}
27
+ end
28
+
29
+ Yes::Core::CommandHandling::CommandGroupHandler.new(self).call(
30
+ group_name, payload, guards:, metadata:
31
+ )
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -66,6 +66,7 @@ module Yes
66
66
  if tp.self == subclass
67
67
  subclass.setup_read_model_classes if subclass.read_model_enabled?
68
68
  subclass.setup_authorizer_classes
69
+ subclass.validate_command_groups!
69
70
  tp.disable
70
71
  end
71
72
  end.enable
@@ -76,8 +77,15 @@ module Yes
76
77
  #
77
78
  # @param name [Symbol] The name of the parent.
78
79
  # @param options [Hash] Options for configuring the parent.
80
+ # @option options [Boolean] :command (true) When false, skips defining the `assign_<name>` command.
81
+ # @option options [Array<Symbol>] :skip_default_guards ([]) Default guards (e.g. `:not_removed`)
82
+ # that should not be auto-applied to the generated `assign_<name>` command. See
83
+ # {.removable} for context on the `:not_removed` auto-block.
79
84
  # @yield Block for defining guards and other attribute configurations.
80
85
  # @return [void]
86
+ #
87
+ # @example Skip the auto-injected :not_removed guard on a parent's assign command
88
+ # parent :tenant, skip_default_guards: %i[not_removed]
81
89
  def parent(name, **options, &)
82
90
  parent_aggregates[name] = options
83
91
 
@@ -85,7 +93,9 @@ module Yes
85
93
 
86
94
  return unless options.fetch(:command, true)
87
95
 
88
- command :"assign_#{name}" do
96
+ skip_default_guards = options[:skip_default_guards] || []
97
+
98
+ command :"assign_#{name}", skip_default_guards: do
89
99
  payload "#{name}_id": :uuid
90
100
 
91
101
  guard(:no_change) { public_send(:"#{name}_id") != payload.public_send(:"#{name}_id") }
@@ -103,7 +113,26 @@ module Yes
103
113
 
104
114
  # Defines a default removal behavior for the aggregate.
105
115
  #
116
+ # In addition to defining the `:remove` command, `removable` records aggregate-level
117
+ # configuration that the {Yes::Core::CommandHandling::GuardEvaluator} reads at runtime
118
+ # to **auto-block every other command on the aggregate** while the removal attribute is
119
+ # set. The auto-block fires before any registered guard (including the auto-injected
120
+ # `:no_change`), so post-remove mutations consistently raise
121
+ # `GuardEvaluator::InvalidTransition` with the i18n message under
122
+ # `aggregates.<context>.<aggregate>.commands.<command>.guards.not_removed.error`.
123
+ # The `:remove` command itself is exempt and remains gated only by `:no_change`.
124
+ #
125
+ # The auto-block is order-independent: `removable` may be declared before or after the
126
+ # other commands on the aggregate.
127
+ #
128
+ # `attr_name` must correspond to an attribute readable on the aggregate (the macro
129
+ # auto-defines it as `:datetime` when missing).
130
+ #
106
131
  # @param attr_name [Symbol] the attribute name to use for marking removal
132
+ # @param not_removed_guards [Boolean] when true (default), every non-`:remove` command on
133
+ # the aggregate auto-blocks while `attr_name` is set. Pass `false` to disable the
134
+ # auto-block aggregate-wide; individual commands can still opt in by defining their
135
+ # own `guard(:not_removed)`.
107
136
  # @yield Block for defining additional guards and other removal configurations
108
137
  # @return [void]
109
138
  #
@@ -124,16 +153,30 @@ module Yes
124
153
  # removable(attr_name: :deleted_at)
125
154
  # end
126
155
  #
127
- def removable(attr_name: :removed_at, &)
156
+ # @example Disable the :not_removed auto-block aggregate-wide
157
+ # class UserAggregate < Yes::Core::Aggregate
158
+ # removable(not_removed_guards: false)
159
+ # end
160
+ #
161
+ def removable(attr_name: :removed_at, not_removed_guards: true, &)
128
162
  attribute attr_name, :datetime unless attributes.key?(attr_name)
163
+ @removable_config = { attr_name:, not_removed_guards: }
129
164
 
130
- command :remove do
165
+ command :remove, skip_default_guards: %i[not_removed] do
131
166
  guard(:no_change) { !public_send(attr_name) }
132
167
  update_state { method(attr_name).call { Time.current } }
133
168
  instance_eval(&) if block_given?
134
169
  end
135
170
  end
136
171
 
172
+ # Returns the removable configuration for the aggregate, or nil if {.removable} was
173
+ # never called.
174
+ #
175
+ # @return [Hash{Symbol => Object}, nil] hash with two keys when set:
176
+ # * `:attr_name` [Symbol] — the attribute that marks removal (default `:removed_at`).
177
+ # * `:not_removed_guards` [Boolean] — whether the auto-block is enabled.
178
+ attr_reader :removable_config
179
+
137
180
  # Sets the primary context for the aggregate.
138
181
  #
139
182
  # @param context [String] The primary context to set.
@@ -239,12 +282,24 @@ module Yes
239
282
  # @example Define publish command an published attribute
240
283
  # command :publish
241
284
  #
242
- def command(*args, **, &)
243
- return handle_command_shortcut(*args, **, &) unless Dsl::CommandShortcutExpander.base_case?(*args, **, &)
285
+ # @example Skip the auto-injected :not_removed guard for a single command
286
+ # command :restore, skip_default_guards: %i[not_removed] do
287
+ # guard(:no_change) { removed_at.present? }
288
+ # update_state { removed_at { nil } }
289
+ # end
290
+ #
291
+ # All overloads accept a `skip_default_guards:` keyword argument carrying an array of
292
+ # default-guard symbols (currently only `:not_removed` — see {.removable}) that should
293
+ # not be auto-applied to the command. Defaults to `[]`.
294
+ #
295
+ def command(*args, **kwargs, &)
296
+ skip_default_guards = kwargs.delete(:skip_default_guards) || []
297
+ base_case = Dsl::CommandShortcutExpander.base_case?(*args, **kwargs, &)
298
+ return handle_command_shortcut(*args, skip_default_guards:, **kwargs, &) unless base_case
244
299
 
245
300
  name = args.first
246
301
  @commands ||= {}
247
- command_data = Dsl::CommandData.new(name, self, { context:, aggregate: })
302
+ command_data = Dsl::CommandData.new(name, self, { context:, aggregate:, skip_default_guards: })
248
303
  @commands[name] = command_data
249
304
 
250
305
  Dsl::CommandDefiner.new(command_data).call(&)
@@ -283,6 +338,55 @@ module Yes
283
338
  @commands ||= {}
284
339
  end
285
340
 
341
+ # Defines a command_group on the aggregate.
342
+ #
343
+ # A command_group declares a compound action that runs multiple
344
+ # existing aggregate commands in declaration order, atomically, with
345
+ # the sub-commands' guards bypassed. The group itself has its own,
346
+ # leaner guard set declared inside the block via `guard :name`.
347
+ #
348
+ # @param name [Symbol] the group name (also the aggregate method name)
349
+ # @yield Block accepting `command :sub_name` and `guard :name`
350
+ # @return [void]
351
+ #
352
+ # @example
353
+ # command_group :create_apprenticeship do
354
+ # command :assign_company
355
+ # command :assign_user
356
+ # command :change_name
357
+ # command :publish
358
+ #
359
+ # guard(:company_assigned) { payload.company_id.present? }
360
+ # end
361
+ def command_group(name, &)
362
+ @command_groups ||= {}
363
+ group_data = Dsl::CommandGroupData.new(name, self, context:, aggregate:)
364
+ @command_groups[name] = group_data
365
+ Dsl::CommandGroupDefiner.new(group_data).call(&)
366
+ end
367
+
368
+ # @return [Hash] The command groups defined on this aggregate
369
+ def command_groups
370
+ @command_groups ||= {}
371
+ end
372
+
373
+ # Validates that each command_group references commands actually
374
+ # defined on this aggregate. Called from the end-of-class
375
+ # {TracePoint} hook set up in {.inherited}.
376
+ #
377
+ # @raise [Dsl::CommandGroupDefiner::UnknownSubCommandError]
378
+ # @return [void]
379
+ def validate_command_groups!
380
+ command_groups.each do |group_name, data|
381
+ unknown = data.sub_command_names - commands.keys
382
+ next if unknown.empty?
383
+
384
+ raise Dsl::CommandGroupDefiner::UnknownSubCommandError,
385
+ "command_group :#{group_name} on #{name} references unknown commands: " \
386
+ "#{unknown.join(', ')}. Define them with `command :<name>` before using them."
387
+ end
388
+ end
389
+
286
390
  private
287
391
 
288
392
  #
@@ -295,8 +399,8 @@ module Yes
295
399
  #
296
400
  # @return [void]
297
401
  #
298
- def handle_command_shortcut(...)
299
- expanded = Dsl::CommandShortcutExpander.new(...).call
402
+ def handle_command_shortcut(*, skip_default_guards: [], **, &)
403
+ expanded = Dsl::CommandShortcutExpander.new(*, **, &).call
300
404
 
301
405
  expanded.attributes.each do |specification|
302
406
  next if attributes.key?(specification.name)
@@ -305,7 +409,7 @@ module Yes
305
409
  end
306
410
 
307
411
  expanded.commands.each do |specification|
308
- command(specification.name, &specification.block)
412
+ command(specification.name, skip_default_guards:, &specification.block)
309
413
  end
310
414
  end
311
415
  end