yes-core 1.2.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 684a9c20dfd17ba779b196a7b294b93e04bf531bcc3d31a2adc9f8a4d645b7dd
4
- data.tar.gz: 43e482658ce60a8c7fe4eb5f73f3bf084169857bd06a57a01aadf5ab7cf98e76
3
+ metadata.gz: 0a7213daeb56ae07a8921c83b912e83b7f9cd81f6d93d90a4394cbe07b7c9299
4
+ data.tar.gz: 58379a04830d2ba08c0ca4431d4e372ee8cc67f8445621d7143c61f1dc27ab9a
5
5
  SHA512:
6
- metadata.gz: 335785676b2baef08117225b60c3a3b170bb39b0c3fe8ffdfa2781c158944b562f53d00884f409fb0c07c15ace4b513c02bd042973da57118ec325701a258acb
7
- data.tar.gz: 72bc5b16b03b00ddc6211806d2566ad5213a25e452836e1ffd09f1e89c10154e98e47edb5ac75573092b1c21ea13cc38f5f49f379f418c2e08c2532d2e498670
6
+ metadata.gz: f5ce8a14fb78e7c9bc1b11264e16a17f0cbdc0ef3dcbbd7175114a1c772097b67cb1226f359429a6be83900f26d0e911dd18df838aa9c9ad5a76cab5d999b889
7
+ data.tar.gz: 01de285d8c90f81625bdd1ff721877b87d23edad1949b1eb860ebd4fc0274c459fbbeb3080e6ecb678fa1cd0174b010b4eb4f146ff00d6e4bea4be935685bb08
data/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.3.0] - 2026-05-18
4
+
5
+ - See root CHANGELOG.md for details.
6
+
3
7
  ## [1.2.0] - 2026-04-30
4
8
 
5
9
  - 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
@@ -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
@@ -337,6 +338,55 @@ module Yes
337
338
  @commands ||= {}
338
339
  end
339
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
+
340
390
  private
341
391
 
342
392
  #