yes-core 1.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 (128) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +5 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +69 -0
  5. data/lib/yes/core/active_job_serializers/command_group_serializer.rb +29 -0
  6. data/lib/yes/core/active_job_serializers/dry_struct_serializer.rb +57 -0
  7. data/lib/yes/core/aggregate/draftable.rb +205 -0
  8. data/lib/yes/core/aggregate/dsl/attribute_data.rb +37 -0
  9. data/lib/yes/core/aggregate/dsl/attribute_definer.rb +54 -0
  10. data/lib/yes/core/aggregate/dsl/attribute_definers/aggregate.rb +36 -0
  11. data/lib/yes/core/aggregate/dsl/attribute_definers/standard.rb +36 -0
  12. data/lib/yes/core/aggregate/dsl/class_name_convention.rb +80 -0
  13. data/lib/yes/core/aggregate/dsl/class_resolvers/authorizer.rb +132 -0
  14. data/lib/yes/core/aggregate/dsl/class_resolvers/base.rb +80 -0
  15. data/lib/yes/core/aggregate/dsl/class_resolvers/command/authorizer.rb +30 -0
  16. data/lib/yes/core/aggregate/dsl/class_resolvers/command/authorizer_factory.rb +34 -0
  17. data/lib/yes/core/aggregate/dsl/class_resolvers/command/base.rb +38 -0
  18. data/lib/yes/core/aggregate/dsl/class_resolvers/command/cerbos_authorizer.rb +114 -0
  19. data/lib/yes/core/aggregate/dsl/class_resolvers/command/command.rb +70 -0
  20. data/lib/yes/core/aggregate/dsl/class_resolvers/command/event.rb +88 -0
  21. data/lib/yes/core/aggregate/dsl/class_resolvers/command/guard_evaluator.rb +84 -0
  22. data/lib/yes/core/aggregate/dsl/class_resolvers/command/simple_authorizer.rb +50 -0
  23. data/lib/yes/core/aggregate/dsl/class_resolvers/command/state_updater.rb +46 -0
  24. data/lib/yes/core/aggregate/dsl/class_resolvers/read_model.rb +75 -0
  25. data/lib/yes/core/aggregate/dsl/class_resolvers/read_model_filter.rb +88 -0
  26. data/lib/yes/core/aggregate/dsl/class_resolvers/read_model_serializer.rb +76 -0
  27. data/lib/yes/core/aggregate/dsl/command_data.rb +54 -0
  28. data/lib/yes/core/aggregate/dsl/command_definer.rb +263 -0
  29. data/lib/yes/core/aggregate/dsl/command_shortcut_expander.rb +233 -0
  30. data/lib/yes/core/aggregate/dsl/constant_resolver.rb +67 -0
  31. data/lib/yes/core/aggregate/dsl/method_definers/attribute/accessor.rb +28 -0
  32. data/lib/yes/core/aggregate/dsl/method_definers/attribute/aggregate_accessor.rb +36 -0
  33. data/lib/yes/core/aggregate/dsl/method_definers/attribute/base.rb +42 -0
  34. data/lib/yes/core/aggregate/dsl/method_definers/command/base.rb +42 -0
  35. data/lib/yes/core/aggregate/dsl/method_definers/command/can_command.rb +41 -0
  36. data/lib/yes/core/aggregate/dsl/method_definers/command/command.rb +50 -0
  37. data/lib/yes/core/aggregate/has_authorizer.rb +86 -0
  38. data/lib/yes/core/aggregate/has_read_model.rb +169 -0
  39. data/lib/yes/core/aggregate/read_model_rebuilder.rb +40 -0
  40. data/lib/yes/core/aggregate/shared_read_model_rebuilder.rb +158 -0
  41. data/lib/yes/core/aggregate.rb +404 -0
  42. data/lib/yes/core/authentication_error.rb +8 -0
  43. data/lib/yes/core/authorization/cerbos_client_provider.rb +27 -0
  44. data/lib/yes/core/authorization/command_authorizer.rb +40 -0
  45. data/lib/yes/core/authorization/command_cerbos_authorizer.rb +182 -0
  46. data/lib/yes/core/authorization/read_model_authorizer.rb +22 -0
  47. data/lib/yes/core/authorization/read_models_authorizer.rb +49 -0
  48. data/lib/yes/core/authorization/read_request_authorizer.rb +32 -0
  49. data/lib/yes/core/authorization/read_request_cerbos_authorizer.rb +112 -0
  50. data/lib/yes/core/command.rb +35 -0
  51. data/lib/yes/core/command_handling/aggregate_tracker.rb +33 -0
  52. data/lib/yes/core/command_handling/command_executor.rb +171 -0
  53. data/lib/yes/core/command_handling/command_handler.rb +124 -0
  54. data/lib/yes/core/command_handling/event_publisher.rb +189 -0
  55. data/lib/yes/core/command_handling/guard_evaluator.rb +165 -0
  56. data/lib/yes/core/command_handling/guard_runner.rb +76 -0
  57. data/lib/yes/core/command_handling/payload_proxy.rb +159 -0
  58. data/lib/yes/core/command_handling/read_model_recovery_service.rb +264 -0
  59. data/lib/yes/core/command_handling/read_model_revision_guard.rb +198 -0
  60. data/lib/yes/core/command_handling/read_model_updater.rb +103 -0
  61. data/lib/yes/core/command_handling/state_updater.rb +113 -0
  62. data/lib/yes/core/commands/bus.rb +46 -0
  63. data/lib/yes/core/commands/group.rb +135 -0
  64. data/lib/yes/core/commands/group_response.rb +13 -0
  65. data/lib/yes/core/commands/helper.rb +126 -0
  66. data/lib/yes/core/commands/notifier.rb +65 -0
  67. data/lib/yes/core/commands/processor.rb +137 -0
  68. data/lib/yes/core/commands/response.rb +63 -0
  69. data/lib/yes/core/commands/stateless/group_handler.rb +186 -0
  70. data/lib/yes/core/commands/stateless/group_response.rb +15 -0
  71. data/lib/yes/core/commands/stateless/handler.rb +292 -0
  72. data/lib/yes/core/commands/stateless/handler_helpers.rb +321 -0
  73. data/lib/yes/core/commands/stateless/response.rb +14 -0
  74. data/lib/yes/core/commands/stateless/subject.rb +41 -0
  75. data/lib/yes/core/commands/validator.rb +28 -0
  76. data/lib/yes/core/configuration.rb +432 -0
  77. data/lib/yes/core/data_decryptor.rb +59 -0
  78. data/lib/yes/core/data_encryptor.rb +60 -0
  79. data/lib/yes/core/encryption_metadata.rb +33 -0
  80. data/lib/yes/core/error.rb +14 -0
  81. data/lib/yes/core/error_messages.rb +37 -0
  82. data/lib/yes/core/event.rb +222 -0
  83. data/lib/yes/core/event_class_resolver.rb +40 -0
  84. data/lib/yes/core/generators/read_models/add_pending_update_tracking_generator.rb +43 -0
  85. data/lib/yes/core/generators/read_models/templates/add_pending_update_tracking.rb.erb +122 -0
  86. data/lib/yes/core/generators/read_models/templates/migration.rb.erb +9 -0
  87. data/lib/yes/core/generators/read_models/update_generator.rb +147 -0
  88. data/lib/yes/core/jobs/read_model_recovery_job.rb +219 -0
  89. data/lib/yes/core/middlewares/encryptor.rb +48 -0
  90. data/lib/yes/core/middlewares/timestamp.rb +29 -0
  91. data/lib/yes/core/middlewares/with_indifferent_access.rb +22 -0
  92. data/lib/yes/core/models/application_record.rb +9 -0
  93. data/lib/yes/core/open_telemetry/otl_span.rb +110 -0
  94. data/lib/yes/core/open_telemetry/trackable.rb +101 -0
  95. data/lib/yes/core/payload_store/base.rb +33 -0
  96. data/lib/yes/core/payload_store/errors.rb +13 -0
  97. data/lib/yes/core/payload_store/lookup.rb +44 -0
  98. data/lib/yes/core/process_managers/access_token_client.rb +107 -0
  99. data/lib/yes/core/process_managers/base.rb +40 -0
  100. data/lib/yes/core/process_managers/command_runner.rb +109 -0
  101. data/lib/yes/core/process_managers/service_client.rb +57 -0
  102. data/lib/yes/core/process_managers/state.rb +118 -0
  103. data/lib/yes/core/railtie.rb +58 -0
  104. data/lib/yes/core/read_model/builder.rb +267 -0
  105. data/lib/yes/core/read_model/event_handler.rb +64 -0
  106. data/lib/yes/core/read_model/filter.rb +118 -0
  107. data/lib/yes/core/read_model/filter_query_builder.rb +104 -0
  108. data/lib/yes/core/serializer.rb +21 -0
  109. data/lib/yes/core/subscriptions.rb +94 -0
  110. data/lib/yes/core/test_support/event_helpers.rb +27 -0
  111. data/lib/yes/core/test_support/jwt_helpers.rb +30 -0
  112. data/lib/yes/core/test_support/subscriptions_helper.rb +88 -0
  113. data/lib/yes/core/test_support/test_helper.rb +27 -0
  114. data/lib/yes/core/test_support.rb +5 -0
  115. data/lib/yes/core/transaction_details.rb +90 -0
  116. data/lib/yes/core/type_lookup.rb +88 -0
  117. data/lib/yes/core/types.rb +110 -0
  118. data/lib/yes/core/utils/aggregate_shortcuts.rb +164 -0
  119. data/lib/yes/core/utils/caller_utils.rb +37 -0
  120. data/lib/yes/core/utils/command_utils.rb +226 -0
  121. data/lib/yes/core/utils/error_notifier.rb +101 -0
  122. data/lib/yes/core/utils/event_name_resolver.rb +67 -0
  123. data/lib/yes/core/utils/exponential_retrier.rb +180 -0
  124. data/lib/yes/core/utils/hash_utils.rb +63 -0
  125. data/lib/yes/core/version.rb +7 -0
  126. data/lib/yes/core.rb +85 -0
  127. data/lib/yes.rb +0 -0
  128. metadata +324 -0
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yes
4
+ module Core
5
+ class Aggregate
6
+ module Dsl
7
+ module ClassResolvers
8
+ module Command
9
+ # Command resolver for simple non-Cerbos authorizers
10
+ class SimpleAuthorizer < Authorizer
11
+ private
12
+
13
+ # Generates a simple authorizer class with a call method
14
+ #
15
+ # @return [Class] The generated authorizer class
16
+ def generate_class
17
+ klass = Class.new(command_data.aggregate_class.authorizer_class)
18
+
19
+ # Only add call method if we have a block
20
+ apply_call_override(klass) if command_data.authorizer_block
21
+
22
+ klass
23
+ end
24
+
25
+ # Adds a `call` class method that executes the user-provided block
26
+ #
27
+ # @param klass [Class] the class being generated
28
+ def apply_call_override(klass)
29
+ user_block = command_data.authorizer_block
30
+
31
+ klass.define_singleton_method(:call) do |cmd, auth|
32
+ @_cmd = cmd
33
+ @_auth = auth
34
+ define_singleton_method(:command) { @_cmd }
35
+ define_singleton_method(:auth_data) { @_auth }
36
+ begin
37
+ instance_exec(&user_block)
38
+ ensure
39
+ @_cmd = nil
40
+ @_auth = nil
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yes
4
+ module Core
5
+ class Aggregate
6
+ module Dsl
7
+ module ClassResolvers
8
+ module Command
9
+ # Creates and registers state updater classes for aggregate commands
10
+ #
11
+ # This class resolver generates plain state updater class that process
12
+ # custom state updates in aggregates.
13
+ #
14
+ class StateUpdater < Base
15
+ private
16
+
17
+ # Returns the class type symbol for the state updater
18
+ #
19
+ # @return [Symbol] Returns :state_updater as the class type
20
+ # @api private
21
+ def class_type
22
+ :state_updater
23
+ end
24
+
25
+ # Returns the name for the state updater class
26
+ #
27
+ # @return [String] The name of the state updater class derived from the command
28
+ # @api private
29
+ def class_name
30
+ command_data.name
31
+ end
32
+
33
+ # Creates a new state updater class
34
+ #
35
+ # @return [Class] A new state updater class inheriting from Yes::Core::CommandHandling::StateUpdater
36
+ # @api private
37
+ def generate_class
38
+ Class.new(Yes::Core::CommandHandling::StateUpdater)
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yes
4
+ module Core
5
+ class Aggregate
6
+ module Dsl
7
+ module ClassResolvers
8
+ # Creates and registers read model classes for aggregates
9
+ #
10
+ # This class resolver generates ActiveRecord-based read model classes
11
+ # that represent the queryable state of aggregates. Each read model class
12
+ # is automatically configured with basic scopes and table name conventions.
13
+ #
14
+ # @example Generated read model class structure
15
+ # class User < ApplicationRecord
16
+ # self.table_name = 'users'
17
+ # scope :by_ids, ->(ids) { where(id: ids) }
18
+ # end
19
+ class ReadModel < Base
20
+ # Initializes a new read model class resolver
21
+ #
22
+ # @param read_model_name [String] The name of the read model
23
+ # @param context_name [String] The name of the context
24
+ # @param aggregate_name [String] The name of the aggregate
25
+ def initialize(read_model_name, context_name, aggregate_name, draft: false)
26
+ @read_model_name = read_model_name
27
+ @draft = draft
28
+
29
+ super(context_name, aggregate_name)
30
+ end
31
+
32
+ # Creates and registers the read model class in the Yes::Core configuration
33
+ #
34
+ # @return [Class] The found or generated read model class that was registered
35
+ def call
36
+ Yes::Core.configuration.register_read_model_class(
37
+ context_name,
38
+ aggregate_name,
39
+ find_or_generate_class,
40
+ draft:
41
+ )
42
+ end
43
+
44
+ private
45
+
46
+ # @return [String] The name of the read model
47
+ attr_reader :read_model_name, :draft
48
+
49
+ # @return [Symbol] Returns :read_model as the class type
50
+ def class_type
51
+ :read_model
52
+ end
53
+
54
+ # @return [String] The name of the read model class
55
+ def class_name
56
+ read_model_name
57
+ end
58
+
59
+ # Generates a new read model class with the required configuration
60
+ #
61
+ # @return [Class] A new read model class inheriting from ApplicationRecord
62
+ def generate_class
63
+ table_name = class_name.tableize
64
+
65
+ Class.new(ApplicationRecord) do
66
+ self.table_name = table_name
67
+ scope :by_ids, ->(ids) { where(id: ids) }
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yes
4
+ module Core
5
+ class Aggregate
6
+ module Dsl
7
+ module ClassResolvers
8
+ # Creates and registers read model filter classes for aggregates
9
+ #
10
+ # This class resolver generates filter classes that provide query scopes
11
+ # for read models. Each filter class is automatically configured with
12
+ # basic scopes and a reference to its corresponding read model class.
13
+ #
14
+ # @example Generated read model filter class structure
15
+ # class UserFilter < Yes::Core::ReadModel::Filter
16
+ # has_scope :ids do |_, scope, value|
17
+ # scope.by_ids(value.split(','))
18
+ # end
19
+ #
20
+ # private
21
+ #
22
+ # def read_model_class
23
+ # Test::User::Aggregate.read_model_class
24
+ # end
25
+ # end
26
+ class ReadModelFilter < Base
27
+ # Initializes a new read model filter class resolver
28
+ #
29
+ # @param read_model_name [String] The name of the read model
30
+ # @param context_name [String] The name of the context
31
+ # @param aggregate_name [String] The name of the aggregate
32
+ def initialize(read_model_name, context_name, aggregate_name)
33
+ @read_model_name = read_model_name
34
+
35
+ super(context_name, aggregate_name)
36
+ end
37
+
38
+ # Creates and registers the read model filter class in the Yes::Core configuration
39
+ #
40
+ # @return [Class] The found or generated read model filter class that was registered
41
+ def call
42
+ Yes::Core.configuration.register_read_model_filter_class(
43
+ context_name,
44
+ aggregate_name,
45
+ find_or_generate_class
46
+ )
47
+ end
48
+
49
+ private
50
+
51
+ # @return [String] The name of the read model
52
+ attr_reader :read_model_name
53
+
54
+ # @return [Symbol] Returns :read_model_filter as the class type
55
+ def class_type
56
+ :read_model_filter
57
+ end
58
+
59
+ # @return [String] The name of the read model filter class
60
+ def class_name
61
+ read_model_name
62
+ end
63
+
64
+ # Generates a new read model filter class with the required scopes and configuration
65
+ #
66
+ # @return [Class] A new filter class inheriting from Yes::Core::ReadModel::Filter
67
+ def generate_class
68
+ klass = Class.new(Yes::Core::ReadModel::Filter)
69
+
70
+ klass.has_scope :ids do |_, scope, value|
71
+ scope.by_ids(value.split(','))
72
+ end
73
+
74
+ # Define read_model_class method
75
+ aggregate_class_name = "#{context_name}::#{aggregate_name}::Aggregate"
76
+ klass.define_method(:read_model_class) do
77
+ aggregate_class_name.constantize.read_model_class
78
+ end
79
+ klass.send :private, :read_model_class
80
+
81
+ klass
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yes
4
+ module Core
5
+ class Aggregate
6
+ module Dsl
7
+ module ClassResolvers
8
+ # Creates and registers read model serializer classes for aggregates
9
+ #
10
+ # This class resolver generates JSON:API compliant serializer classes
11
+ # for read models. Each serializer class is automatically configured with
12
+ # the specified attributes and type naming conventions.
13
+ #
14
+ # @example Generated read model serializer class structure
15
+ # class UserSerializer < Yes::Core::Serializer
16
+ # set_type 'users'
17
+ # attributes :id, :email, :first_name, :last_name
18
+ # end
19
+ class ReadModelSerializer < Base
20
+ # Initializes a new read model serializer class resolver
21
+ #
22
+ # @param read_model_name [String] The name of the read model
23
+ # @param context_name [String] The name of the context
24
+ # @param aggregate_name [String] The name of the aggregate
25
+ # @param read_model_attributes [Array<Symbol>] The attributes to be serialized
26
+ def initialize(read_model_name, context_name, aggregate_name, read_model_attributes)
27
+ @read_model_name = read_model_name
28
+ @read_model_attributes = read_model_attributes
29
+
30
+ super(context_name, aggregate_name)
31
+ end
32
+
33
+ # Creates and registers the read model serializer class in the Yes::Core configuration
34
+ #
35
+ # @return [Class] The found or generated read model serializer class that was registered
36
+ def call
37
+ Yes::Core.configuration.register_read_model_filter_class(
38
+ context_name,
39
+ aggregate_name,
40
+ find_or_generate_class
41
+ )
42
+ end
43
+
44
+ private
45
+
46
+ # @return [String] The name of the read model
47
+ # @return [Array<Symbol>] The attributes to be serialized
48
+ attr_reader :read_model_name, :read_model_attributes
49
+
50
+ # @return [Symbol] Returns :read_model_serializer as the class type
51
+ def class_type
52
+ :read_model_serializer
53
+ end
54
+
55
+ # @return [String] The name of the read model serializer class
56
+ def class_name
57
+ read_model_name
58
+ end
59
+
60
+ # Generates a new read model serializer class with the required configuration
61
+ #
62
+ # @return [Class] A new serializer class inheriting from Yes::Core::Serializer
63
+ def generate_class
64
+ klass = Class.new(Yes::Core::Serializer)
65
+
66
+ klass.set_type read_model_name.pluralize
67
+ klass.attributes(*read_model_attributes)
68
+
69
+ klass
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,54 @@
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 definition in an aggregate
8
+ #
9
+ # @example
10
+ # CommandData.new(:assign_user, UserAggregate, context: 'Users', aggregate: 'User')
11
+ #
12
+ class CommandData
13
+ attr_reader :name, :context_name, :aggregate_name, :aggregate_class
14
+ attr_accessor :event_name, :payload_attributes, :update_state_block, :guard_names, :authorizer_block,
15
+ :encrypted_attributes
16
+
17
+ # @param name [Symbol] The name of the command
18
+ # @param aggregate_class [Class] The aggregate class this command belongs to
19
+ # @param options [Hash] Additional options for the command
20
+ # @option options [String] :context The context name for the command
21
+ # @option options [String] :aggregate The aggregate name
22
+ # @option options [String] :event_name The event name for the command
23
+ # @option options [Hash] :payload_attributes The payload attributes for the command
24
+ def initialize(name, aggregate_class, options = {})
25
+ @name = name
26
+ @aggregate_class = aggregate_class
27
+ @context_name = options.delete(:context)
28
+ @aggregate_name = options.delete(:aggregate)
29
+
30
+ # Default event name based on command name (will be overridden if specified in DSL)
31
+ @event_name = options.delete(:event_name) || Yes::Core::Utils::EventNameResolver.call(name)
32
+
33
+ # Default payload is just the aggregate_id
34
+ @payload_attributes = options.delete(:payload_attributes) || {}
35
+
36
+ # Store guard names
37
+ @guard_names = []
38
+
39
+ # Track encrypted attributes for event schema generation
40
+ @encrypted_attributes = []
41
+ end
42
+
43
+ # Add a guard name to the list of guards
44
+ #
45
+ # @param name [Symbol] The name of the guard
46
+ # @return [void]
47
+ def add_guard(name)
48
+ @guard_names << name
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,263 @@
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 commands on aggregates.
8
+ # Handles the creation and registration of all necessary command-related classes,
9
+ # including validation of attributes and DSL evaluation.
10
+ #
11
+ # @example
12
+ # command_data = CommandData.new(name: :assign_user, aggregate_class: Company)
13
+ # CommandDefiner.new(command_data).call do
14
+ # payload user_id: :uuid
15
+ #
16
+ # guard :user_already_assigned do
17
+ # user_id.present?
18
+ # end
19
+ #
20
+ # update_state do
21
+ # some_attribute { payload.xyz }
22
+ # another_attribute { "#{payload.abc}_#{email}" }
23
+ # end
24
+ # end
25
+ #
26
+ class CommandDefiner
27
+ # Error raised when attributes are used in the command that are not defined on the aggregate
28
+ class UndefinedAttributeError < Yes::Core::Error; end
29
+
30
+ # Error raised when event name cannot be resolved
31
+ class EventNameResolverError < Yes::Core::Error; end
32
+
33
+ # @return [CommandData] The data object containing command configuration
34
+ attr_reader :command_data
35
+ private :command_data
36
+
37
+ # Initializes a new CommandDefiner instance
38
+ #
39
+ # @param command_data [CommandData] The data object containing command configuration
40
+ # @return [CommandDefiner] A new instance of CommandDefiner
41
+ def initialize(command_data)
42
+ @command_data = command_data
43
+ end
44
+
45
+ # Generates and registers all necessary classes for the command.
46
+ # This includes command classes, event classes, a guard evaluator class,
47
+ # a state updater class, as well as defining related methods on the aggregate class.
48
+ #
49
+ # @yield Block for defining payload, guards and other command configurations
50
+ # @yieldreturn [void]
51
+ # @return [void]
52
+ # @raise [UndefinedAttributeError] If attributes used in the command are not defined on the aggregate
53
+ def call(&block)
54
+ create_and_register_block_evaluator_classes
55
+ evaluate_dsl_block(&block) if block
56
+ validate_event_name
57
+ validate_accessed_attributes
58
+ create_and_register_command_classes
59
+ register_command_events
60
+ end
61
+
62
+ private
63
+
64
+ # Validates that the event name is present (either because it was specified explicitly or because it was
65
+ # resolved from the command name).
66
+ #
67
+ # @return [void]
68
+ # @raise [EventNameResolverError] If the event name is not specified explicitly
69
+ def validate_event_name
70
+ return if command_data.event_name
71
+
72
+ raise EventNameResolverError,
73
+ "Event name for command #{command_data.context_name}::#{command_data.aggregate_name} " \
74
+ "#{command_data.name} cannot be resolved, please specify explicitly."
75
+ end
76
+
77
+ # Creates and registers the guard evaluator and state updater classes
78
+ #
79
+ # @return [void]
80
+ def create_and_register_block_evaluator_classes
81
+ @guard_evaluator_class = ClassResolvers::Command::GuardEvaluator.new(command_data).call
82
+ @state_updater_class = ClassResolvers::Command::StateUpdater.new(command_data).call
83
+ end
84
+
85
+ # Creates and registers command-related classes and defines their methods
86
+ #
87
+ # @return [void]
88
+ def create_and_register_command_classes
89
+ @command_class = ClassResolvers::Command::Command.new(command_data).call
90
+ @event_class = ClassResolvers::Command::Event.new(command_data).call
91
+
92
+ MethodDefiners::Command::Command.new(command_data).call
93
+ MethodDefiners::Command::CanCommand.new(command_data).call
94
+ end
95
+
96
+ # Validates attributes based on whether the command uses update_state or payload
97
+ #
98
+ # @return [void]
99
+ # @raise [UndefinedAttributeError] If any attributes are not defined on the aggregate
100
+ def validate_accessed_attributes
101
+ if command_data.update_state_block
102
+ validate_attributes!(
103
+ @state_updater_class.updated_attributes,
104
+ 'update_state block uses attributes'
105
+ )
106
+ else
107
+ validate_attributes!(
108
+ extract_payload_attribute_names,
109
+ 'payload attributes'
110
+ )
111
+ end
112
+ end
113
+
114
+ # Extract attribute names from payload_attributes, handling the new format for optional attributes
115
+ #
116
+ # @return [Array<Symbol>] The attribute names
117
+ def extract_payload_attribute_names
118
+ command_data.payload_attributes.keys.without(:locale)
119
+ end
120
+
121
+ # Validates that all given attributes are defined on the aggregate
122
+ #
123
+ # @param attributes [Array<Symbol>] The attributes to validate
124
+ # @param context [String] The context in which these attributes are being used
125
+ # @return [void]
126
+ # @raise [UndefinedAttributeError] If any of the attributes are not defined on the aggregate
127
+ def validate_attributes!(attributes, context)
128
+ return if attributes.empty?
129
+
130
+ aggregate_attributes = command_data.aggregate_class.attributes
131
+ aggregate_type_aggregate_attributes = aggregate_attributes.select { _2 == :aggregate }
132
+ undefined_attributes = attributes.reject do |attr_name|
133
+ aggregate_attributes.key?(attr_name) ||
134
+ aggregate_type_aggregate_attributes.key?(attr_name.to_s.delete_suffix('_id').to_sym)
135
+ end
136
+
137
+ return if undefined_attributes.empty?
138
+
139
+ raise UndefinedAttributeError, "Command '#{command_data.name}' #{context} " \
140
+ "that are not defined on the aggregate: #{undefined_attributes.join(', ')}. " \
141
+ "Please define these attributes using the 'attribute' method first."
142
+ end
143
+
144
+ def register_command_events
145
+ Yes::Core.configuration.register_command_events(
146
+ command_data.context_name,
147
+ command_data.aggregate_name,
148
+ command_data.name,
149
+ [command_data.event_name]
150
+ )
151
+ end
152
+
153
+ # Evaluates the DSL block in the context of a DslEvaluator
154
+ #
155
+ # @yield The block to evaluate
156
+ # @yieldreturn [void]
157
+ # @return [void]
158
+ def evaluate_dsl_block(&block)
159
+ return unless block
160
+
161
+ dsl_evaluator = DslEvaluator.new(command_data, @guard_evaluator_class, @state_updater_class)
162
+ dsl_evaluator.instance_eval(&block)
163
+ end
164
+
165
+ # DSL evaluator class for command configuration blocks
166
+ class DslEvaluator
167
+ # @return [CommandData] The command data being configured
168
+ # @return [Class] The guard evaluator class for this command
169
+ # @return [Class] The state updater class for this command
170
+ attr_reader :command_data, :guard_evaluator_class, :state_updater_class
171
+
172
+ # @param command_data [CommandData] The command data to configure
173
+ # @param guard_evaluator_class [Class] The guard evaluator class for this command
174
+ # @param state_updater_class [Class] The state updater class for this command
175
+ def initialize(command_data, guard_evaluator_class, state_updater_class)
176
+ @command_data = command_data
177
+ @guard_evaluator_class = guard_evaluator_class
178
+ @state_updater_class = state_updater_class
179
+ end
180
+
181
+ # Defines a guard for the command
182
+ #
183
+ # @param name [Symbol] The name of the guard
184
+ # @param error_extra [Hash, Proc] The extra information to be added to the error message payload
185
+ # @yield The guard evaluation block
186
+ # @yieldreturn [Boolean] True if the guard passes, false otherwise
187
+ # @return [void]
188
+ def guard(name, error_extra: {}, &)
189
+ command_data.add_guard(name)
190
+ guard_evaluator_class.guard(name, error_extra:, &)
191
+ end
192
+
193
+ # Defines the payload for the command
194
+ # Supports inline encryption declaration: payload email: { type: :email, encrypt: true }
195
+ #
196
+ # @param attributes [Hash] The attributes for the payload
197
+ # @return [void]
198
+ def payload(attributes)
199
+ normalized = attributes.transform_values do |value|
200
+ case value
201
+ when Hash
202
+ # Extract encrypt flag if present, pass everything else through
203
+ value.except(:encrypt)
204
+ else
205
+ # Handle simple syntax: :email or :string
206
+ value
207
+ end
208
+ end
209
+
210
+ # Collect encrypted attributes
211
+ attributes.each do |key, value|
212
+ command_data.encrypted_attributes << key if value.is_a?(Hash) && value[:encrypt]
213
+ end
214
+
215
+ command_data.payload_attributes = normalized
216
+ end
217
+
218
+ # Declares which payload attributes should be encrypted in the generated event
219
+ #
220
+ # @param attribute_names [Array<Symbol>] The names of payload attributes to encrypt
221
+ # @return [void]
222
+ # @example
223
+ # command :update_contact_info do
224
+ # payload email: :email, phone: :phone
225
+ # encrypt :email, :phone
226
+ # end
227
+ def encrypt(*attribute_names)
228
+ command_data.encrypted_attributes.concat(attribute_names)
229
+ end
230
+
231
+ # Defines how the state should be updated
232
+ #
233
+ # @param custom [Boolean] Whether the state should be updated using a custom block
234
+ # @yield Block defining how to update the state
235
+ # @yieldreturn [void]
236
+ # @return [void]
237
+ def update_state(custom: false, &block)
238
+ command_data.update_state_block = block
239
+ state_updater_class.update_state(custom:, &block)
240
+ end
241
+
242
+ # Defines the event name for the command
243
+ #
244
+ # @param name [Symbol] The name of the event
245
+ # @return [void]
246
+ def event(name)
247
+ command_data.event_name = name
248
+ end
249
+
250
+ # Overwrites the authorizer for the command
251
+ #
252
+ # @yield The authorizer block
253
+ # @yieldreturn [void]
254
+ # @return [void]
255
+ def authorize(&block)
256
+ command_data.authorizer_block = block
257
+ end
258
+ end
259
+ end
260
+ end
261
+ end
262
+ end
263
+ end