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,267 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'i18n'
4
+
5
+ begin
6
+ require 'trx_ext'
7
+ rescue LoadError
8
+ end
9
+
10
+ module Yes
11
+ module Core
12
+ module ReadModel
13
+ # Base class for read model builders that distribute events to proper event handlers
14
+ # and manage read model lifecycle including rebuilding.
15
+ class Builder
16
+ include OpenTelemetry::Trackable
17
+
18
+ EventValidationErrorsPresent = Class.new(Error)
19
+ InvalidReadModelBuilderClass = Class.new(Error)
20
+ MissingReadModelId = Class.new(Error)
21
+ RebuildError = Class.new(Error)
22
+
23
+ READ_MODEL_CLASS_REGEXP =
24
+ /(?<context>\w+)::ReadModels::(?<version>V\d+)?(::)?(?<aggregate>\w+)/
25
+
26
+ # Based on event type class it distributes event to a proper event handler
27
+ # @param event [Yes::Core::Event]
28
+ # @param read_model [ActiveRecord::Base] AR object
29
+ # @return [void]
30
+ def call(event, read_model: nil)
31
+ read_model ||= read_model(event)
32
+
33
+ otl_record_data(read_model, event)
34
+
35
+ handler = handler_class(event)&.new(read_model)
36
+ return unless handler
37
+
38
+ locale = event.data['locale']&.to_sym || I18n.default_locale
39
+ return unless correct_locale?(locale)
40
+
41
+ I18n.with_locale(locale) do
42
+ next handler.call(event) unless self.class.otl_tracer
43
+
44
+ OpenTelemetry::OtlSpan.new(otl_data: otl_data(handler), otl_tracer: self.class.otl_tracer).otl_span do
45
+ handler.call(event)
46
+ end
47
+ ensure
48
+ otl_record_processed(read_model, event, handler)
49
+ end
50
+ end
51
+ otl_trackable :call, OpenTelemetry::OtlSpan::OtlData.new(
52
+ span_kind: :consumer,
53
+ links_extractor: ->(event, **) { event.metadata['otl_contexts'] },
54
+ track_sql: true
55
+ )
56
+
57
+ # @param eventstore [PgEventstore::Client] eventstore client
58
+ # @param ids [Array<String>] ids to rebuild
59
+ def rebuild_many(
60
+ eventstore: PgEventstore.client,
61
+ ids: read_model_class.pluck(:id)
62
+ )
63
+ ids.each { |id| rebuild(eventstore:, id:) }
64
+ end
65
+
66
+ # @param id [String] id of the read model to rebuild
67
+ # @param eventstore [PgEventstore::Client] eventstore client
68
+ # @return [ActiveRecord::Base, nil]
69
+ # @raise [Yes::Core::ReadModel::Builder::RebuildError]
70
+ def rebuild(id:, eventstore: PgEventstore.client)
71
+ # delete is intentional to avoid any callbacks
72
+ read_model_class.delete(id) if read_model_class.exists?(id)
73
+ context = read_model_class_name_match[:context]
74
+ stream_name = read_model_class_name_match[:aggregate]
75
+ enum = eventstore.read_paginated(
76
+ PgEventstore::Stream.new(context:, stream_name:, stream_id: id), options: { resolve_link_tos: true }
77
+ )
78
+ read_model = read_model_class.find_or_create_by(id:)
79
+
80
+ enum.each do |events|
81
+ events.each do |event|
82
+ # pass in read model for efficiency
83
+ call(event, read_model:)
84
+ end
85
+ end
86
+ read_model
87
+ end
88
+
89
+ # @return [String]
90
+ def aggregate_id_key
91
+ "#{underscore(read_model_class_name_match[:aggregate].to_s)}_id"
92
+ end
93
+
94
+ def initialize
95
+ self.read_model_class = default_read_model_class
96
+ end
97
+
98
+ protected
99
+
100
+ attr_accessor :read_model_class
101
+
102
+ # @param event [Yes::Core::Event] event to find handler for
103
+ # @return [Class, nil] handler class for the event
104
+ def handler_class(event)
105
+ handler_classes = possible_handler_class_names(event)
106
+ handler_instance = handler_classes.lazy.filter_map do |class_name|
107
+ Kernel.const_get(class_name)
108
+ rescue NameError
109
+ next
110
+ end.first
111
+
112
+ notify_missing_event_handler(event, handler_classes) unless handler_instance
113
+
114
+ handler_instance
115
+ end
116
+
117
+ # @param event [Yes::Core::Event] event to find handler names for
118
+ # @return [Array<String>] possible handler class names
119
+ def possible_handler_class_names(event)
120
+ parent_modules = parent_modules_string
121
+ event_type = event_type_name(event)
122
+ event_context = event_context_name(event)
123
+ # in case event has no context
124
+ return ["#{parent_modules}::On#{event_type}"] if event_type == event_context
125
+
126
+ [
127
+ "#{parent_modules}::#{event_context}::On#{event_type}",
128
+ "#{parent_modules}::On#{event_type}"
129
+ ]
130
+ end
131
+
132
+ # @param event [Yes::Core::Event] event that has no handler
133
+ # @param possible_handler_classes_name [Array<String>] handler class names that were tried
134
+ def notify_missing_event_handler(event, possible_handler_classes_name)
135
+ msg = "The event handler #{possible_handler_classes_name} is not defined."
136
+ Utils::ErrorNotifier.new.event_handler_not_defined(msg, event)
137
+ end
138
+
139
+ # @param event [Yes::Core::Event] event to extract type name from
140
+ # @return [String]
141
+ def event_type_name(event)
142
+ event.type.split('::').last
143
+ end
144
+
145
+ # @param event [Yes::Core::Event] event to extract context name from
146
+ # @return [String]
147
+ def event_context_name(event)
148
+ event.type.split('::').first
149
+ end
150
+
151
+ # @return [String]
152
+ def parent_modules_string
153
+ self.class.to_s.split('::')[0..-2].join('::')
154
+ end
155
+
156
+ # @return [Class] default read model class inferred from builder class name
157
+ # @raise [InvalidReadModelBuilderClass] if class name doesn't match expected pattern
158
+ def default_read_model_class
159
+ match = read_model_class_name_match
160
+ klass = [match[:version], match[:aggregate]].compact.join('::')
161
+ Kernel.const_get klass
162
+ rescue NameError, TypeError
163
+ msg = "The Read Model Builder Class #{self.class} does not match modules structure"
164
+ raise InvalidReadModelBuilderClass, msg
165
+ end
166
+
167
+ private
168
+
169
+ # @return [MatchData] match data from class name regex
170
+ def read_model_class_name_match
171
+ @read_model_class_name_match ||=
172
+ READ_MODEL_CLASS_REGEXP.match(self.class.to_s)
173
+ end
174
+
175
+ # @param locale [Symbol] locale to check
176
+ # @return [Boolean] true if locale is available
177
+ def correct_locale?(locale)
178
+ return true unless locale
179
+
180
+ I18n.available_locales.map(&:to_sym).include?(locale.to_sym)
181
+ end
182
+
183
+ # @param event [Yes::Core::Event] event to find read model for
184
+ # @return [ActiveRecord::Base] the read model instance
185
+ # @raise [MissingReadModelId] if event data doesn't contain aggregate id
186
+ def read_model(event)
187
+ read_model_id = event.data[aggregate_id_key]
188
+ raise MissingReadModelId.new(extra: event) unless read_model_id
189
+
190
+ find_or_create_read_model(read_model_id)
191
+ end
192
+
193
+ if defined?(TrxExt)
194
+ def find_or_create_read_model(read_model_id)
195
+ read_model_class.trx do
196
+ read_model_class.find_by(id: read_model_id) || read_model_class.create(id: read_model_id)
197
+ end
198
+ end
199
+ else
200
+ def find_or_create_read_model(read_model_id)
201
+ read_model_class.create_or_find_by!(id: read_model_id)
202
+ end
203
+ end
204
+
205
+ # derived from https://api.rubyonrails.org/classes/ActiveSupport/Inflector.html#method-i-underscore
206
+ # @param word [String] word to underscore
207
+ # @return [String] underscored word
208
+ def underscore(word)
209
+ w = word.dup
210
+ w.gsub!(/([A-Z\d]+)([A-Z][a-z])/, '\1_\2')
211
+ w.gsub!(/([a-z\d])([A-Z])/, '\1_\2')
212
+ w.tr!('-', '_')
213
+ w.downcase
214
+ end
215
+
216
+ # @param read_model [ActiveRecord::Base] the read model
217
+ # @param event [Yes::Core::Event] the event
218
+ # @param handler [Yes::Core::ReadModel::EventHandler] the handler that processed the event
219
+ def otl_record_processed(_read_model, event, handler)
220
+ return if ENV['STATSD_ADDR'].blank?
221
+
222
+ StatsD.increment(
223
+ 'events_processing_total',
224
+ tags: {
225
+ type: 'consumer',
226
+ service: Rails.application.class.module_parent.name,
227
+ source: "#{event.metadata.dig('otl_contexts', 'root', 'service') || 'unknown'}-#{event.type}",
228
+ target: "#{Rails.application.class.module_parent.name}-#{self.class.name}",
229
+ read_model_builder_name: self.class.name,
230
+ event_handler_name: handler.class.name,
231
+ event: event.type
232
+ }
233
+ )
234
+ end
235
+
236
+ # @param read_model [ActiveRecord::Base] the read model
237
+ # @param event [Yes::Core::Event] the event
238
+ def otl_record_data(read_model, event)
239
+ return unless self.class.otl_tracer
240
+ return if event.created_at.blank?
241
+
242
+ span_time = (Time.at(0, self.class.current_span.start_timestamp, :nanosecond).to_f * 1000).to_i
243
+ event_publish_delay_ms = span_time - (event.created_at.utc.to_f * 1000).to_i
244
+
245
+ self.class.current_span&.add_attributes(
246
+ {
247
+ 'event_published_delay_ms' => event_publish_delay_ms,
248
+ 'event_published_at_ms' => (event.created_at.to_f * 1000).to_i,
249
+ 'command_request_started_at_ms' => event.metadata.dig('otl_contexts', 'timestamps',
250
+ 'command_request_started_at_ms'),
251
+ 'command_handling_started_at_ms' => event.metadata.dig('otl_contexts', 'timestamps',
252
+ 'command_handling_started_at_ms'),
253
+ 'read_model_builder.name' => self.class.name,
254
+ 'read_model_builder.read_model.class' => read_model.class.name
255
+ }.compact_blank
256
+ )
257
+ end
258
+
259
+ # @param handler [Yes::Core::ReadModel::EventHandler] handler to build OTL data for
260
+ # @return [Yes::Core::OpenTelemetry::OtlSpan::OtlData]
261
+ def otl_data(handler)
262
+ OpenTelemetry::OtlSpan::OtlData.new(span_name: handler.class.name, span_kind: :consumer, track_sql: true)
263
+ end
264
+ end
265
+ end
266
+ end
267
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yes
4
+ module Core
5
+ module ReadModel
6
+ # Base class for event handlers that process events and update read models.
7
+ class EventHandler
8
+ include OpenTelemetry::Trackable
9
+
10
+ attr_accessor :read_model, :payload_store_lookup
11
+ private :read_model, :payload_store_lookup, :read_model=, :payload_store_lookup=
12
+
13
+ # @param read_model [ActiveRecord::Base] AR object
14
+ # @param payload_store_lookup [#call] payload store lookup instance
15
+ def initialize(
16
+ read_model,
17
+ payload_store_lookup: Yes::Core::PayloadStore::Lookup.new
18
+ )
19
+ raise ArgumentError unless read_model
20
+
21
+ self.read_model = read_model
22
+ self.payload_store_lookup = payload_store_lookup
23
+ end
24
+
25
+ # @param event [Yes::Core::Event] event to handle
26
+ # @return [Yes::Core::Event] the processed event
27
+ def call(event)
28
+ otl_record_event_data(event) if self.class.otl_tracer
29
+
30
+ payload_store_lookup.call(event).each do |key, value|
31
+ event.data[key.to_s] = value
32
+ end
33
+
34
+ event
35
+ end
36
+ otl_trackable :call, OpenTelemetry::OtlSpan::OtlData.new(span_kind: :consumer, track_sql: true)
37
+
38
+ private
39
+
40
+ # @param event [Yes::Core::Event] event to record telemetry data for
41
+ def otl_record_event_data(event)
42
+ return if event.created_at.blank?
43
+
44
+ span_time = (Time.at(0, self.class.current_span.start_timestamp, :nanosecond).to_f * 1000).to_i
45
+ event_publish_delay_ms = span_time - (event.created_at.utc.to_f * 1000).to_i
46
+
47
+ self.class.current_span&.add_attributes(
48
+ {
49
+ 'event_published_delay_ms' => event_publish_delay_ms,
50
+ 'event_published_at_ms' => (event.created_at.utc.to_f * 1000).to_i,
51
+ 'command_request_started_at_ms' => event.metadata.dig('otl_contexts', 'timestamps',
52
+ 'command_request_started_at_ms'),
53
+ 'command_handling_started_at_ms' => event.metadata.dig('otl_contexts', 'timestamps',
54
+ 'command_handling_started_at_ms'),
55
+ 'event.type' => event.type,
56
+ 'event.data' => event.data.to_json,
57
+ 'event.metadata' => event.metadata.to_json
58
+ }.compact_blank
59
+ )
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'has_scope'
4
+
5
+ module Yes
6
+ module Core
7
+ module ReadModel
8
+ # Inherit from this class to define your implementation. Your class should implement #read_model_class instance
9
+ # method should determine what ActiveRecord model class to use.
10
+ # Example usage:
11
+ # ```ruby
12
+ # class JobAppFilter < Yes::Core::ReadModel::Filter
13
+ # has_scope :ids do |_controller, scope, value|
14
+ # scope.where(id: value.split(','))
15
+ # end
16
+ # has_scope :order_by_name do |_controller, scope, value|
17
+ # scope.order(name: value)
18
+ # end
19
+ # has_scope :order_by_id do |_controller, scope, value|
20
+ # scope.order(id: value)
21
+ # end
22
+ #
23
+ # private
24
+ #
25
+ # def read_model_class
26
+ # ::JobApp
27
+ # end
28
+ # end
29
+ # ```
30
+ #
31
+ # Now you can use it as follows:
32
+ # ```ruby
33
+ # JobAppFilter.new(order: { name: 'asc', id: 'desc' }, filters: { ids: '1,2,3' }).call
34
+ # ```
35
+ class Filter
36
+ include HasScope
37
+ include FilterQueryBuilder
38
+
39
+ ORDER_DIRECTIONS = {
40
+ 'asc' => 'asc', 'desc' => 'desc', '0' => 'asc', '1' => 'desc'
41
+ }.tap { |h| h.default = 'asc' }.freeze
42
+
43
+ attr_accessor :options, :type
44
+ private :options, :type
45
+
46
+ # Returns scope of persisted filters if given filter supports saving.
47
+ # e. g. `AdvancedFilter.where(read_model: 'apprenticeships')`
48
+ # @return [ActiveRecord::Relation]
49
+ def self.persisted_filter_scope
50
+ raise NotImplementedError
51
+ end
52
+
53
+ # @param options [Hash]
54
+ # @option [Hash] :order defines an order of records of your query.
55
+ # Example: { order: { name: 'asc', id: 'desc' } }.
56
+ # Note: you should have `:order_by_name` and `:order_by_id` scopes defined using `#has_scope` class method
57
+ # @option [Hash] :filters defines a filtering of records of your query.
58
+ # Example: { filters: { ids: '1,2,3', name: 'Awasome-4000' } }
59
+ # Note: you should have `:ids` scope defined using `#has_scope` class method
60
+ # @option [Hash] :filter_definition defines an advanced filter supporting linking multiple filters with AND/OR
61
+ # logic.
62
+ # Example:
63
+ # {
64
+ # filter_definition: {
65
+ # type: 'filter_set',
66
+ # logical_operator: 'or',
67
+ # filters: [
68
+ # { type: 'filter', attribute: 'first_name', operator: 'is', value: 'Foo' },
69
+ # { type: 'filter', attribute: 'last_name', operator: 'is_not', value: 'Bar' }
70
+ # ]
71
+ # }
72
+ # }
73
+ # @param [Symbol] :type defines the type of filter to use, default is :basic. Passing :advanced is required to
74
+ # use the :filter_definition option.
75
+ def initialize(options, type: :basic)
76
+ @options = options
77
+ @type = type
78
+ end
79
+
80
+ # @return [ActiveRecord::Relation]
81
+ def call
82
+ scope = read_model_class.order(id: :asc)
83
+ if type == :basic
84
+ scope = apply_scopes(scope, options[:filters]) if options[:filters].is_a?(Hash)
85
+ else
86
+ scope = process_advanced_filter(scope, options[:filter_definition])
87
+ end
88
+ scope = apply_scopes(scope.reorder(nil), ordering_options(options[:order])) if options[:order].is_a?(Hash)
89
+ scope
90
+ end
91
+
92
+ private
93
+
94
+ # Transforms a hash into a set of scopes names and normalized directions.
95
+ # Example:
96
+ # Given options:
97
+ # ```ruby
98
+ # { name: '1' }
99
+ # ```
100
+ # Will result in:
101
+ # ```ruby
102
+ # { order_by_name: 'desc' }
103
+ # ```
104
+ # @param options [Hash] order options
105
+ # @return [Hash]
106
+ def ordering_options(options)
107
+ options.each_with_object({}) do |(column_name, direction), result|
108
+ result[:"order_by_#{column_name}"] = ORDER_DIRECTIONS[direction]
109
+ end
110
+ end
111
+
112
+ def read_model_class
113
+ raise NotImplementedError
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yes
4
+ module Core
5
+ module ReadModel
6
+ # Provides advanced filter query building capabilities using AND/OR logic.
7
+ # Include this module in filter classes that need to support advanced filtering.
8
+ module FilterQueryBuilder
9
+ AND_LOGICAL_OPERATOR = 'and'
10
+ OR_LOGICAL_OPERATOR = 'or'
11
+ IS_OPERATOR = 'is'
12
+ IS_NOT_OPERATOR = 'is_not'
13
+
14
+ FilterSchema = Dry::Schema.Params do
15
+ required(:type).value(eql?: 'filter')
16
+ required(:attribute).filled(:string)
17
+ required(:operator).value(included_in?: [IS_NOT_OPERATOR, IS_OPERATOR])
18
+ required(:value).value(:any)
19
+ end
20
+
21
+ FilterSetSchema = Dry::Schema.Params do
22
+ required(:type).value(eql?: 'filter_set')
23
+ required(:logical_operator).value(included_in?: [AND_LOGICAL_OPERATOR, OR_LOGICAL_OPERATOR])
24
+ required(:filters).array(FilterSchema)
25
+ optional(:scope).value(:hash)
26
+ end
27
+
28
+ private
29
+
30
+ # Builds an advanced filter query based on the filter definition.
31
+ #
32
+ # @param base_advanced_filter_scope [ActiveRecord::Relation] The base scope to apply the filter to.
33
+ # @param filter_definition [Hash] The filter definition to use.
34
+ #
35
+ # @return [ActiveRecord::Relation]
36
+ def process_advanced_filter(base_advanced_filter_scope, filter_definition)
37
+ return base_advanced_filter_scope if filter_definition.blank?
38
+ raise 'Invalid filter definition' unless FilterSetSchema.call(filter_definition).success?
39
+
40
+ @base_advanced_filter_scope = base_advanced_filter_scope
41
+ @filter_definition = filter_definition.deep_symbolize_keys
42
+ apply_main_scope
43
+
44
+ return build_and_query if @filter_definition[:logical_operator] == AND_LOGICAL_OPERATOR
45
+
46
+ build_or_query
47
+ end
48
+
49
+ # Chains multiple filters with AND logic.
50
+ #
51
+ # @return [ActiveRecord::Relation]
52
+ def build_and_query
53
+ scope = @base_advanced_filter_scope
54
+ @filter_definition[:filters].each do |filter|
55
+ scope =
56
+ if filter[:operator] == IS_OPERATOR
57
+ apply_is_filter(scope, filter)
58
+ else
59
+ apply_is_not_filter(scope, filter)
60
+ end
61
+ end
62
+ scope
63
+ end
64
+
65
+ # Chains multiple filters with OR logic.
66
+ #
67
+ # @return [ActiveRecord::Relation]
68
+ def build_or_query
69
+ scopes = @filter_definition[:filters].map do |filter|
70
+ if filter[:operator] == IS_OPERATOR
71
+ apply_is_filter(@base_advanced_filter_scope, filter)
72
+ else
73
+ apply_is_not_filter(@base_advanced_filter_scope, filter)
74
+ end
75
+ end
76
+
77
+ scope = scopes.first
78
+ scopes[1..].each do |additional_scope|
79
+ scope = scope.or(additional_scope)
80
+ end
81
+
82
+ scope
83
+ end
84
+
85
+ # Applies given scope to the base advanced filter scope to support authorization.
86
+ def apply_main_scope
87
+ return if @filter_definition[:scope].blank?
88
+
89
+ @base_advanced_filter_scope = apply_scopes(@base_advanced_filter_scope, @filter_definition[:scope])
90
+ end
91
+
92
+ # Applies a single filter to the scope.
93
+ def apply_is_filter(scope, filter)
94
+ apply_scopes(scope, { filter[:attribute].to_sym => filter[:value] })
95
+ end
96
+
97
+ # Applies negation of a single filter to the scope.
98
+ def apply_is_not_filter(scope, filter)
99
+ scope.where.not(id: apply_is_filter(@base_advanced_filter_scope, filter))
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'jsonapi/serializer'
4
+
5
+ module Yes
6
+ module Core
7
+ # Base JSON:API serializer for read models.
8
+ #
9
+ # All auto-generated and user-defined serializers inherit from this class.
10
+ # Wraps the jsonapi-serializer gem.
11
+ #
12
+ # @example
13
+ # class UserSerializer < Yes::Core::Serializer
14
+ # set_type 'users'
15
+ # attributes :id, :email, :first_name, :last_name
16
+ # end
17
+ class Serializer
18
+ include JSONAPI::Serializer
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+
5
+ module Yes
6
+ module Core
7
+ # Manages PgEventstore subscriptions with optional heartbeat and OpenTelemetry tracing.
8
+ #
9
+ # @example
10
+ # subscriptions = Yes::Core::Subscriptions.new
11
+ # subscriptions.subscribe_to_all(handler, filter_opts)
12
+ # subscriptions.start
13
+ class Subscriptions
14
+ include Yes::Core::OpenTelemetry::Trackable
15
+
16
+ # @return [Integer] Timeout in seconds for subscriptions to start
17
+ SUBSCRIPTIONS_START_TIMEOUT = 20
18
+
19
+ # @return [PgEventstore::SubscriptionsManager] the subscriptions manager
20
+ attr_reader :subscriptions_manager
21
+
22
+ # @return [PgEventstore::Client] the event store client
23
+ attr_reader :client
24
+
25
+ # Initializes subscriptions with the given PgEventstore config.
26
+ #
27
+ # @param config_name [Symbol] the PgEventstore configuration name
28
+ def initialize(config_name: :default)
29
+ @subscriptions_manager = PgEventstore.subscriptions_manager(
30
+ config_name,
31
+ subscription_set: Rails.application.class.name.split('::').first
32
+ )
33
+ @client = PgEventstore.client(config_name)
34
+ end
35
+
36
+ # Subscribes a handler to all events matching the given filter.
37
+ #
38
+ # @param handler [#call] the event handler
39
+ # @param filter_opts [Hash] PgEventstore filter options
40
+ # @param subscription_opts [Hash] additional subscription options
41
+ # @return [void]
42
+ def subscribe_to_all(handler, filter_opts, **subscription_opts)
43
+ subscriptions_manager.subscribe(
44
+ handler.class.to_s,
45
+ handler: self.class.otl_tracer ? otl_trackable_handler(handler) : handler,
46
+ options: { filter: filter_opts, resolve_link_tos: true },
47
+ **subscription_opts
48
+ )
49
+ end
50
+
51
+ # Starts the subscriptions manager and optional heartbeat.
52
+ #
53
+ # @return [void]
54
+ def start
55
+ start_heartbeat if Yes::Core.configuration.subscriptions_heartbeat_url.present?
56
+ subscriptions_manager.start
57
+ end
58
+
59
+ private
60
+
61
+ # Wraps a handler with OpenTelemetry tracing.
62
+ #
63
+ # @param handler [#call] the event handler to wrap
64
+ # @return [Proc] the wrapped handler
65
+ def otl_trackable_handler(handler)
66
+ proc do |event|
67
+ otl_data = Yes::Core::OpenTelemetry::OtlSpan::OtlData.new(
68
+ span_name: handler.class.name,
69
+ span_kind: :consumer,
70
+ links_extractor: ->(ev, **) { ev.metadata['otl_contexts'] },
71
+ track_sql: true
72
+ )
73
+ Yes::Core::OpenTelemetry::OtlSpan.new(otl_data:, otl_tracer: self.class.otl_tracer).
74
+ otl_span(event) { handler.call(event) }
75
+ end
76
+ end
77
+
78
+ # Starts a background thread that periodically sends a heartbeat HTTP GET request.
79
+ #
80
+ # @return [Thread] the heartbeat thread
81
+ def start_heartbeat
82
+ Thread.new do
83
+ loop do
84
+ sleep Yes::Core.configuration.subscriptions_heartbeat_interval
85
+ uri = URI(Yes::Core.configuration.subscriptions_heartbeat_url)
86
+ Net::HTTP.get(uri)
87
+ rescue StandardError => e
88
+ Rails.logger.warn("Subscriptions heartbeat failed: #{e.message}") if defined?(Rails)
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yes
4
+ module Core
5
+ module TestSupport
6
+ # Helpers for working with PgEventstore events in tests.
7
+ module EventHelpers
8
+ # @param stream [PgEventstore::Stream]
9
+ # @param event [Yes::Core::Event]
10
+ # @return [Yes::Core::Event]
11
+ def append_and_reload_event(stream, event)
12
+ PgEventstore.client.append_to_stream(stream, event)
13
+ PgEventstore.client.read(stream, options: { max_count: 1, direction: :desc }).first
14
+ end
15
+
16
+ # Reads eventstore and returns events from the stream or an empty array if stream does not exist
17
+ # @param stream [PgEventstore::Stream]
18
+ # @return [Array<Yes::Core::Event>]
19
+ def safe_read(stream)
20
+ PgEventstore.client.read(stream)
21
+ rescue PgEventstore::StreamNotFoundError
22
+ []
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end