the-active-rpc 1.0.1

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.
@@ -0,0 +1,1456 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ostruct'
4
+ require_relative 'model_extensions/attribute_dsl'
5
+
6
+ # This module provides a DSL for integrating gRPC services with ActiveRecord models
7
+ # It automatically creates methods to access attributes from gRPC responses
8
+ # IMPORTANT: This concern is designed to be used ONLY with ActiveRecord models
9
+ module ActiveRpc
10
+ module ModelExtensions
11
+ extend ActiveSupport::Concern
12
+
13
+ def self.prepare_value_for_rpc(value, type = nil)
14
+ prepared =
15
+ if type&.respond_to?(:rpc_serialize)
16
+ type.rpc_serialize(value)
17
+ elsif value.respond_to?(:rpc_serialize)
18
+ value.rpc_serialize
19
+ elsif value.respond_to?(:as_rpc_payload)
20
+ value.as_rpc_payload
21
+ elsif value.respond_to?(:as_json)
22
+ value.as_json
23
+ else
24
+ value
25
+ end
26
+
27
+ if defined?(ActiveSupport::HashWithIndifferentAccess) && prepared.is_a?(ActiveSupport::HashWithIndifferentAccess)
28
+ prepared = prepared.to_h
29
+ end
30
+
31
+ case prepared
32
+ when Hash
33
+ prepared.deep_stringify_keys
34
+ when Array
35
+ prepared.map { |item| prepare_value_for_rpc(item, nil) }
36
+ else
37
+ prepared
38
+ end
39
+ end
40
+
41
+ included do
42
+ # Include ActiveModel::Attributes for attribute management
43
+ include ActiveModel::Attributes
44
+
45
+ # Include ActiveModel::Dirty for change tracking
46
+ include ActiveModel::Dirty
47
+
48
+ # Class attribute to store the gRPC client for direct access
49
+ class_attribute :grpc_client, instance_writer: false
50
+
51
+ # Store remote attributes for proper initialization
52
+ class_attribute :remote_attributes, instance_writer: false, default: {}
53
+
54
+ # Override the reload method to also reload remote data
55
+ def reload(*args)
56
+ # Call the original reload method
57
+ result = super
58
+
59
+ # Reload remote data
60
+ load_remote_data if respond_to?(:load_remote_data)
61
+
62
+ # Return self for method chaining
63
+ result
64
+ end
65
+
66
+ # Define a method to load all remote data
67
+ def load_remote_data
68
+ # Return early if active_rpc is not configured for this model
69
+ return unless self.class.respond_to?(:active_rpc_config) && self.class.active_rpc_config
70
+
71
+ # Get all active_rpc configurations
72
+ self.class.active_rpc_config&.dig(:options, :method_name).then do |method_name|
73
+ # Load the remote data
74
+ send("load_#{method_name}") if method_name && respond_to?("load_#{method_name}")
75
+ end
76
+ end
77
+ end
78
+
79
+ class_methods do
80
+ # Helper method to make locale-aware gRPC calls
81
+ # This ensures proper I18N support for localized models
82
+ def grpc_call_with_locale(method, params = {})
83
+ current_locale = defined?(I18n) ? I18n.locale&.to_s : nil
84
+ Rails.logger.debug "[ACTIVE_RPC DEBUG] grpc_call_with_locale: method=#{method}, locale=#{current_locale}"
85
+
86
+ begin
87
+ if current_locale && !current_locale.empty?
88
+ # Create metadata hash with locale (ensure string values)
89
+ metadata = { 'locale' => current_locale.to_s }
90
+ result = grpc_client.call(method, params, metadata)
91
+ else
92
+ result = grpc_client.call(method, params)
93
+ end
94
+
95
+ result
96
+ rescue => e
97
+ raise e
98
+ end
99
+ end
100
+
101
+ # DSL method to configure gRPC integration
102
+ # Example: active_rpc :core, :User, foreign_key: :user_id do
103
+ # define_rpc_attribute :name, :string, default: "Anonymous"
104
+ # define_rpc_attribute :email, :string
105
+ # define_rpc_attribute :roles, :json, default: []
106
+ # end
107
+ def active_rpc(service, resource, options = {}, &block)
108
+ # Create a configuration object to store attributes
109
+ config = OpenStruct.new(
110
+ service: service,
111
+ resource: resource,
112
+ attributes: [],
113
+ attribute_types: {},
114
+ attribute_defaults: {},
115
+ query_config: {},
116
+ scopes: {},
117
+ where_rpc_mappings: {},
118
+ options: options
119
+ )
120
+
121
+ # Create a DSL context for defining attributes
122
+ if block_given?
123
+ dsl = AttributeDsl.new(config)
124
+ dsl.instance_eval(&block)
125
+ end
126
+
127
+ # Get the final list of attributes
128
+ attributes = config.attributes
129
+ rpc_attribute_names = attributes.map(&:to_s)
130
+ # Default options
131
+ options = {
132
+ foreign_key: :"#{resource.to_s.underscore}_id",
133
+ method_name: :"#{resource.to_s.underscore}_data",
134
+ reload_method: :"reload_#{resource.to_s.underscore}",
135
+ cache_expires_in: 30.minutes,
136
+ bulk_method: :"list_#{resource.to_s.pluralize.underscore}"
137
+ }.merge(options)
138
+
139
+ # Store configuration in class instance variable for better inheritance support
140
+ @active_rpc_config = {
141
+ service: service,
142
+ resource: resource,
143
+ attributes: config.attributes,
144
+ attribute_types: config.attribute_types,
145
+ attribute_defaults: config.attribute_defaults,
146
+ query_config: config.query_config,
147
+ scopes: config.scopes,
148
+ where_rpc_mappings: config.where_rpc_mappings,
149
+ options: options
150
+ }
151
+
152
+ # Store in remote_attributes for proper initialization
153
+ self.remote_attributes = remote_attributes.merge(
154
+ resource.to_s.underscore.to_sym => {
155
+ attributes: config.attributes,
156
+ attribute_types: config.attribute_types,
157
+ attribute_defaults: config.attribute_defaults,
158
+ query_config: config.query_config,
159
+ scopes: config.scopes,
160
+ where_rpc_mappings: config.where_rpc_mappings,
161
+ service: service,
162
+ options: options
163
+ }
164
+ )
165
+
166
+ # Initialize class variables for validation errors
167
+ @last_validation_errors = nil
168
+ @last_validation_messages = nil
169
+
170
+ # Initialize the gRPC client for this model
171
+ begin
172
+ self.grpc_client = ActiveRpc::ClientFactory.instance.client_for(service.to_s, "#{resource}s")
173
+ rescue StandardError => e
174
+ raise e unless defined?(Rails)
175
+
176
+ Rails.logger.error("Failed to initialize gRPC client for #{service}:#{resource}: #{e.message}")
177
+ # In production, we'll use a dummy client to prevent app crash
178
+ if defined?(Rails.env) && Rails.env.production?
179
+ self.grpc_client = ActiveRpc::DummyClient.new(service.to_s, "#{resource}s")
180
+ end
181
+
182
+ # Re-raise in non-Rails environments
183
+ end
184
+
185
+ # Define method to access the configuration
186
+ define_singleton_method(:active_rpc_config) do
187
+ @active_rpc_config
188
+ end
189
+
190
+ # Define attributes using ActiveModel::Attributes
191
+ config.attributes.each do |attr|
192
+ attr_name = attr.to_s
193
+
194
+ # Get the type for this attribute
195
+ attr_type = config.attribute_types[attr] || :string
196
+
197
+ # Get default value if specified
198
+ default_value = config.attribute_defaults[attr]
199
+
200
+ # Define the attribute with the appropriate type and default value
201
+ if !default_value.nil?
202
+ # Use the specified default value
203
+ attribute attr_name, attr_type, default: default_value
204
+ else
205
+ # No default value
206
+ attribute attr_name, attr_type
207
+ end
208
+ end
209
+
210
+ # Define getter methods for all models
211
+ config.attributes.each do |attr|
212
+ attr_name = attr.to_s
213
+
214
+ # Override the getter method to check for cached values first
215
+ define_method(attr_name) do
216
+ # Check if we have a cached value from bulk loading
217
+ cached_var = "@#{attr_name}_cached"
218
+ cached_loaded_var = "@#{attr_name}_cached_loaded"
219
+
220
+ return instance_variable_get(cached_var) if instance_variable_get(cached_loaded_var)
221
+
222
+ super()
223
+ end
224
+ end
225
+
226
+ # Add an after_find callback to load remote data when a model is loaded from the database
227
+ # This ensures that remote attributes are available when using Model.find
228
+ # BUT only if the data hasn't already been cached from bulk loading
229
+ after_find :"load_#{options[:method_name]}_if_needed"
230
+
231
+ # This callback is now handled by the combined before_save callback above
232
+
233
+ # Add a before_save callback to handle both creation and updates
234
+ before_save do
235
+ # Skip RPC operations when in local mode (e.g., during seeding)
236
+ if ActiveRpc.local_mode?
237
+ if defined?(Rails) && Rails.respond_to?(:logger)
238
+ Rails.logger.debug("[ActiveRpc Local Mode] Skipping RPC operations for #{resource} during save")
239
+ end
240
+ next
241
+ end
242
+
243
+ # Handle creation - if we have pending attributes but no foreign key
244
+ if send(options[:foreign_key]).blank? && send("has_#{resource.to_s.underscore}_updates?")
245
+ data = send("pending_#{resource.to_s.underscore}_updates")
246
+
247
+ # Only create if there are actual changes
248
+ result = send("create_and_associate_#{resource.to_s.underscore}", data)
249
+
250
+ # For debugging
251
+ if defined?(Rails) && Rails.respond_to?(:logger)
252
+ Rails.logger.debug("[ActiveRpc] create_and_associate result: #{result}, foreign_key: #{send(options[:foreign_key]).inspect}")
253
+ end
254
+
255
+ # If the creation failed, abort the save
256
+ unless result
257
+ if defined?(Rails) && Rails.respond_to?(:logger)
258
+ Rails.logger.error("[ActiveRpc] Failed to create and associate #{resource} before creating #{self.class.name}")
259
+ end
260
+ throw :abort
261
+ end
262
+ end
263
+
264
+ # Handle updates - if we have pending updates and a foreign key
265
+ if send("has_#{resource.to_s.underscore}_updates?") && send(options[:foreign_key]).present?
266
+ # Save the updates to the gRPC service
267
+ result = send("update_#{resource.to_s.underscore}")
268
+
269
+ # If the update failed, abort the save
270
+ throw :abort unless result
271
+ end
272
+ end
273
+
274
+ # Add a method to track changes to delegated attributes
275
+ define_method("#{resource.to_s.underscore}_changed?") do
276
+ # Check if any of the remote attributes have changed using ActiveModel::Dirty
277
+ attributes.any? { |attr| respond_to?("#{attr}_changed?") && send("#{attr}_changed?") }
278
+ end
279
+
280
+ # Define method to get data from the gRPC service
281
+ define_method(options[:method_name]) do
282
+ # Create a mock message with the current attribute values
283
+ message = OpenStruct.new(id: send(options[:foreign_key]))
284
+
285
+ # Add all attributes to the message
286
+ config.attributes.each do |attr|
287
+ # Get the current value of the attribute
288
+ message.send("#{attr}=", send(attr))
289
+ end
290
+
291
+ # Return a mock response with the message
292
+ OpenStruct.new(message: message)
293
+ end
294
+
295
+ # Define method to force reload data from the gRPC service
296
+ define_method(options[:reload_method]) do
297
+ send("load_#{options[:method_name]}")
298
+ self
299
+ end
300
+
301
+ # Define method to load data from the gRPC service
302
+ define_method("load_#{options[:method_name]}") do
303
+ foreign_key_value = send(options[:foreign_key])
304
+ return unless foreign_key_value.present?
305
+
306
+ # Fetch data from the gRPC service
307
+ data = send("fetch_#{options[:method_name]}_from_#{service}")
308
+
309
+ # Set attribute values directly
310
+ return unless data&.message
311
+
312
+ # Update attributes with the response data
313
+ if data.message.respond_to?(:to_h)
314
+ # Convert the response message to a hash
315
+ response_data = data.message.to_h
316
+
317
+ # Use intersection to get only the attributes that exist in both the response and our model
318
+ attrs_to_assign = response_data.slice(*attributes.map(&:to_sym))
319
+
320
+ # Assign the attributes in one go
321
+ assign_attributes(attrs_to_assign)
322
+ end
323
+
324
+ # Clear remote attribute changes since these are the initial values
325
+ clear_attribute_changes(rpc_attribute_names) if respond_to?(:clear_attribute_changes)
326
+ end
327
+
328
+ # Define conditional method to load data only if not already cached from bulk loading
329
+ define_method("load_#{options[:method_name]}_if_needed") do
330
+ # Check if data is already loaded (from bulk loading or previous individual load)
331
+ return if instance_variable_get("@#{options[:method_name]}_loaded")
332
+
333
+ # Check if we have bulk loading data available using the record's cache key
334
+ # This happens when the record was loaded as part of a with_user_data scope
335
+ cache_key = instance_variable_get(:@_bulk_loading_cache_key) || Thread.current[:active_rpc_current_cache_key]
336
+ if cache_key
337
+ # Set the cache key on this record for future use
338
+ instance_variable_set(:@_bulk_loading_cache_key, cache_key)
339
+ cache_hash = self.class.instance_variable_get(:@_bulk_loading_cache) || {}
340
+ cache_data = cache_hash[cache_key]
341
+
342
+ if cache_data && cache_data[:lookup]
343
+ foreign_key_value = send(options[:foreign_key])
344
+ remote_data = cache_data[:lookup][foreign_key_value.to_s] if foreign_key_value.present?
345
+
346
+ if remote_data
347
+ # Cache the gRPC response
348
+ mock_response = OpenStruct.new(message: remote_data)
349
+ instance_variable_set("@#{options[:method_name]}", mock_response)
350
+ instance_variable_set("@#{options[:method_name]}_loaded", true)
351
+
352
+ # Cache individual attributes
353
+ bulk_attributes = cache_data[:attributes] || attributes
354
+ bulk_attributes.each do |attr|
355
+ next unless remote_data.respond_to?(attr)
356
+
357
+ value = remote_data.send(attr)
358
+ instance_variable_set("@#{attr}_cached", value)
359
+ instance_variable_set("@#{attr}_cached_loaded", true)
360
+ end
361
+ return
362
+ end
363
+ end
364
+ end
365
+
366
+ # Check if this record was filtered out by where_rpc conditions
367
+ if instance_variable_get(:@_rpc_data_filtered_out)
368
+ # Set empty/default values to avoid repeated attempts
369
+ instance_variable_set("@#{options[:method_name]}", nil)
370
+ instance_variable_set("@#{options[:method_name]}_loaded", true)
371
+
372
+ # Cache default values for attributes
373
+ attributes.each do |attr|
374
+ default_value = config.attribute_defaults[attr]
375
+ instance_variable_set("@#{attr}_cached", default_value)
376
+ instance_variable_set("@#{attr}_cached_loaded", true)
377
+ end
378
+ return
379
+ end
380
+
381
+ # Check if we're in a where_rpc context and should skip individual loading
382
+ if Thread.current[:active_rpc_where_rpc_context]
383
+ # Set empty/default values to avoid repeated attempts
384
+ instance_variable_set("@#{options[:method_name]}", nil)
385
+ instance_variable_set("@#{options[:method_name]}_loaded", true)
386
+
387
+ # Cache default values for attributes
388
+ attributes.each do |attr|
389
+ default_value = config.attribute_defaults[attr]
390
+ instance_variable_set("@#{attr}_cached", default_value)
391
+ instance_variable_set("@#{attr}_cached_loaded", true)
392
+ end
393
+ return
394
+ end
395
+
396
+ send("load_#{options[:method_name]}")
397
+ end
398
+
399
+ # Define method to fetch data from the gRPC service
400
+ define_method("fetch_#{options[:method_name]}_from_#{service}") do
401
+ foreign_key_value = send(options[:foreign_key])
402
+
403
+ # Check if local mode is enabled - skip gRPC calls entirely
404
+ if ActiveRpc.local_mode?
405
+ Rails.logger.debug("[ActiveRpc Local Mode] Skipping gRPC call for #{resource} with #{options[:foreign_key]}=#{foreign_key_value}")
406
+
407
+ # Create a mock response with default values
408
+ message_data = { id: foreign_key_value }
409
+ config.attributes.each do |attr|
410
+ message_data[attr] = config.attribute_defaults[attr] if config.attribute_defaults.key?(attr)
411
+ end
412
+
413
+ return OpenStruct.new(message: OpenStruct.new(message_data))
414
+ end
415
+
416
+ # Add nil check before calling methods on the client
417
+ if self.class.grpc_client.nil?
418
+ Rails.logger.error("Failed to fetch #{resource} data for #{options[:foreign_key]}=#{send(options[:foreign_key])}: gRPC client is nil")
419
+ return nil
420
+ end
421
+
422
+ formatted_id = ActiveRpc::ModelExtensions.format_foreign_key_value(self.class, options[:foreign_key],
423
+ foreign_key_value)
424
+ response = self.class.grpc_call_with_locale(:"Get#{resource}", id: formatted_id)
425
+
426
+ # Add nil check for the response
427
+ if response.nil?
428
+ Rails.logger.error("Failed to fetch #{resource} data for #{options[:foreign_key]}=#{send(options[:foreign_key])}: gRPC response is nil")
429
+ return nil
430
+ end
431
+
432
+ response
433
+ rescue StandardError => e
434
+ Rails.logger.error("Failed to fetch #{resource} data for #{options[:foreign_key]}=#{send(options[:foreign_key])}: #{e.message}")
435
+ nil
436
+ end
437
+
438
+ # NOTE: Bulk method is defined later with query_options support (line ~1083)
439
+
440
+ # Define a scope for eager loading gRPC data
441
+ scope_method_name = "with_#{resource.to_s.underscore}_data".to_sym
442
+ define_singleton_method(scope_method_name) do |query_options = {}|
443
+ # Return a relation that can be chained, but mark it for bulk loading
444
+ relation = all
445
+
446
+ # Store the bulk loading configuration in the relation
447
+ relation.instance_variable_set(:@_bulk_loading_enabled, true)
448
+ relation.instance_variable_set(:@_bulk_loading_resource, resource)
449
+ # Include resource in options for generic access
450
+ bulk_options = options.merge(resource: resource)
451
+ relation.instance_variable_set(:@_bulk_loading_options, bulk_options)
452
+ relation.instance_variable_set(:@_bulk_loading_query_options, query_options)
453
+ relation.instance_variable_set(:@_bulk_loading_attributes, attributes)
454
+
455
+ # IDs will be extracted when bulk loading is first triggered
456
+
457
+ # Create a unique cache key for this relation to avoid collisions between different relations
458
+ @@bulk_loading_counter ||= 0
459
+ @@bulk_loading_counter += 1
460
+ cache_key = "bulk_loading_#{@@bulk_loading_counter}"
461
+ relation.instance_variable_set(:@_bulk_loading_cache_key, cache_key)
462
+
463
+ # Create a unique cache key for this relation to avoid collisions
464
+ cache_key = "bulk_loading_#{relation.object_id}"
465
+ relation.instance_variable_set(:@_bulk_loading_cache_key, cache_key)
466
+
467
+ # Create a unique cache key for this specific relation
468
+ cache_key = "bulk_loading_#{relation.object_id}_#{Time.now.to_f}"
469
+ relation.instance_variable_set(:@_bulk_loading_cache_key, cache_key)
470
+
471
+ # Method to add RPC filtering conditions using Ransack-style queries
472
+ # This sends filters to the gRPC service's 'q' parameter
473
+ relation.define_singleton_method(:filter_rpc) do |conditions = {}|
474
+ Rails.logger.debug("[ActiveRpc] filter_rpc called with conditions: #{conditions.keys}") if defined?(Rails)
475
+
476
+ # Store the conditions for later processing as Ransack queries
477
+ @_filter_rpc_conditions = (@_filter_rpc_conditions || {}).merge(conditions)
478
+
479
+ # Return self to allow chaining
480
+ self
481
+ end
482
+
483
+ # Method to call remote scopes for complex queries
484
+ # This sends scope calls to the gRPC service's 'scope' parameter
485
+ relation.define_singleton_method(:rpc_scope) do |scope_name, value = nil, **args|
486
+ if defined?(Rails)
487
+ Rails.logger.debug("[ActiveRpc] rpc_scope called: #{scope_name} with value: #{value}, args: #{args}")
488
+ end
489
+
490
+ # Store the scope calls for later processing
491
+ # If value is provided, use it directly; otherwise use the first arg value
492
+ scope_value = value || (args.any? ? args.values.first : nil)
493
+ @_rpc_scope_conditions = (@_rpc_scope_conditions || {}).merge(scope_name => scope_value)
494
+
495
+ # Return self to allow chaining
496
+ self
497
+ end
498
+
499
+ # Helper method to check if we have any RPC conditions
500
+ relation.define_singleton_method(:has_rpc_conditions?) do
501
+ @_filter_rpc_conditions.present? || @_rpc_scope_conditions.present?
502
+ end
503
+
504
+ # Helper method to apply RPC filters and call the original method
505
+ relation.define_singleton_method(:apply_rpc_filters_then_call) do |method_name, *args, &block|
506
+ Rails.logger.debug("[ActiveRpc] apply_rpc_filters_then_call called for #{method_name}") if defined?(Rails)
507
+
508
+ # Check if we have any RPC filtering conditions to process
509
+ has_conditions = has_rpc_conditions?
510
+
511
+ if has_conditions
512
+ Rails.logger.debug('[ActiveRpc] Making gRPC call with RPC conditions') if defined?(Rails)
513
+
514
+ # Determine bulk method based on resource type
515
+ resource = @_bulk_loading_options[:resource] || 'User'
516
+ default_bulk_method = "list_#{resource.to_s.pluralize.underscore}".to_sym
517
+ bulk_method = @_bulk_loading_options[:bulk_method] || default_bulk_method
518
+
519
+ # Prepare query options for different types of RPC calls
520
+ query_options = {}
521
+
522
+ # 1. Handle filter_rpc conditions (Ransack queries)
523
+ if @_filter_rpc_conditions.present?
524
+ if defined?(Rails)
525
+ Rails.logger.debug("[ActiveRpc] Processing filter_rpc conditions: #{@_filter_rpc_conditions.inspect}")
526
+ end
527
+ query_options[:q] = @_filter_rpc_conditions
528
+ end
529
+
530
+ # 2. Handle rpc_scope conditions (remote scopes)
531
+ if @_rpc_scope_conditions.present?
532
+ if defined?(Rails)
533
+ Rails.logger.debug("[ActiveRpc] Processing rpc_scope conditions: #{@_rpc_scope_conditions.inspect}")
534
+ end
535
+ existing_scopes = query_options[:scope] || {}
536
+ query_options[:scope] = existing_scopes.merge(@_rpc_scope_conditions)
537
+ end
538
+
539
+ Rails.logger.debug("[ActiveRpc] Final query_options: #{query_options.inspect}") if defined?(Rails)
540
+
541
+ filtered_data = klass.send(bulk_method, [], query_options)
542
+ filtered_ids = filtered_data.map(&:id)
543
+ resource_name = @_bulk_loading_options[:resource] || 'records'
544
+ if defined?(Rails)
545
+ Rails.logger.debug("[ActiveRpc] Got #{filtered_data.size} #{resource_name.to_s.downcase} from gRPC call")
546
+ end
547
+
548
+ # 2. Handle empty results
549
+ if filtered_ids.empty?
550
+ Rails.logger.debug('[ActiveRpc] No filtered results, returning empty relation') if defined?(Rails)
551
+ # For count method, return 0 directly to avoid infinite recursion
552
+ return 0 if method_name == :count
553
+
554
+ # For other methods, create empty relation from current relation to preserve ActiveRpc overrides
555
+ empty_relation = none
556
+ # Clear RPC conditions to prevent infinite recursion
557
+ empty_relation.instance_variable_set(:@_filter_rpc_conditions, {})
558
+ empty_relation.instance_variable_set(:@_rpc_scope_conditions, {})
559
+ return empty_relation.send(method_name, *args, &block) if block_given?
560
+
561
+ return empty_relation.send(method_name, *args)
562
+
563
+ end
564
+
565
+ # 3. Create new relation with filtered IDs
566
+ foreign_key = @_bulk_loading_options[:foreign_key]
567
+ if defined?(Rails)
568
+ Rails.logger.debug("[ActiveRpc] Creating new relation with #{foreign_key} IN #{filtered_ids.size} IDs")
569
+ end
570
+ new_relation = where(foreign_key => filtered_ids)
571
+
572
+ # 4. Clear all RPC conditions to prevent infinite recursion
573
+ # Keep the bulk_loading_options so the relation retains its ActiveRpc configuration
574
+ new_relation.instance_variable_set(:@_filter_rpc_conditions, {})
575
+ new_relation.instance_variable_set(:@_rpc_scope_conditions, {})
576
+
577
+ # 5. Call the original method on the new relation with proper argument handling
578
+ result = if block_given?
579
+ new_relation.send(method_name, *args, &block)
580
+ else
581
+ new_relation.send(method_name, *args)
582
+ end
583
+ Rails.logger.debug("[ActiveRpc] #{method_name} completed with result: #{result.class}") if defined?(Rails)
584
+ result
585
+ elsif block_given?
586
+ # No RPC conditions, use normal behavior with proper argument handling
587
+ super(*args, &block)
588
+ else
589
+ super(*args)
590
+ end
591
+ end
592
+
593
+ # Override enumeration methods to use new RPC filter pattern
594
+ relation.define_singleton_method(:each) do |&block|
595
+ if has_rpc_conditions?
596
+ apply_rpc_filters_then_call(:each, &block)
597
+ else
598
+ super(&block)
599
+ end
600
+ end
601
+
602
+ relation.define_singleton_method(:map) do |&block|
603
+ if has_rpc_conditions?
604
+ apply_rpc_filters_then_call(:map, &block)
605
+ else
606
+ super(&block)
607
+ end
608
+ end
609
+
610
+ relation.define_singleton_method(:to_a) do
611
+ if has_rpc_conditions?
612
+ apply_rpc_filters_then_call(:to_a)
613
+ else
614
+ super()
615
+ end
616
+ end
617
+
618
+ # Override individual record access methods to use new RPC filter pattern
619
+ relation.define_singleton_method(:first) do |limit = nil|
620
+ if has_rpc_conditions?
621
+ apply_rpc_filters_then_call(:first, limit)
622
+ else
623
+ super(limit)
624
+ end
625
+ end
626
+
627
+ relation.define_singleton_method(:second) do
628
+ if has_rpc_conditions?
629
+ apply_rpc_filters_then_call(:second)
630
+ else
631
+ super()
632
+ end
633
+ end
634
+
635
+ relation.define_singleton_method(:last) do |limit = nil|
636
+ if has_rpc_conditions?
637
+ apply_rpc_filters_then_call(:last, limit)
638
+ else
639
+ super(limit)
640
+ end
641
+ end
642
+
643
+ relation.define_singleton_method(:[]) do |index|
644
+ if has_rpc_conditions?
645
+ apply_rpc_filters_then_call(:[], index)
646
+ else
647
+ super(index)
648
+ end
649
+ end
650
+
651
+ # Override evaluation methods to use new RPC filter pattern
652
+ relation.define_singleton_method(:count) do |column_name = nil|
653
+ if has_rpc_conditions?
654
+ Rails.logger.debug('[ActiveRpc] Using RPC filter pattern for count') if defined?(Rails)
655
+ apply_rpc_filters_then_call(:count, column_name)
656
+ else
657
+ # Use the original ActiveRecord count method directly
658
+ ActiveRecord::Relation.instance_method(:count).bind_call(self, column_name)
659
+ end
660
+ end
661
+
662
+ relation.define_singleton_method(:size) do
663
+ if has_rpc_conditions?
664
+ apply_rpc_filters_then_call(:size)
665
+ else
666
+ super()
667
+ end
668
+ end
669
+
670
+ relation.define_singleton_method(:inspect) do
671
+ if has_rpc_conditions?
672
+ apply_rpc_filters_then_call(:inspect)
673
+ else
674
+ super()
675
+ end
676
+ end
677
+
678
+ relation.define_singleton_method(:length) do
679
+ if has_rpc_conditions?
680
+ apply_rpc_filters_then_call(:length)
681
+ else
682
+ super()
683
+ end
684
+ end
685
+
686
+ relation.define_singleton_method(:empty?) do
687
+ if has_rpc_conditions?
688
+ apply_rpc_filters_then_call(:empty?)
689
+ else
690
+ super()
691
+ end
692
+ end
693
+
694
+ # NOTE: Old post-filtering method removed - using new RPC filter pattern
695
+
696
+ # Define the bulk loading method
697
+ relation.define_singleton_method(:perform_bulk_loading_if_needed) do
698
+ return if @_bulk_loading_performed
699
+
700
+ # Extract IDs from the current relation state and store for reuse
701
+ bulk_options = @_bulk_loading_options
702
+
703
+ # Check if we have where_rpc conditions that should be applied remotely
704
+ has_remote_conditions = @_bulk_loading_query_options&.dig(:where)&.present? ||
705
+ @_bulk_loading_query_options&.dig(:scope)&.present?
706
+
707
+ Rails.logger.debug("[ActiveRpc] has_remote_conditions: #{has_remote_conditions}") if defined?(Rails)
708
+ Rails.logger.debug("[ActiveRpc] query_options: #{@_bulk_loading_query_options.keys}") if defined?(Rails)
709
+
710
+ if has_remote_conditions
711
+ # For remote filtering, don't extract IDs - let the remote service filter
712
+ Rails.logger.debug('[ActiveRpc] Using remote filtering - not extracting IDs') if defined?(Rails)
713
+ ids = []
714
+ else
715
+ # For regular bulk loading, extract IDs as usual
716
+ if @_bulk_loading_extracted_ids
717
+ ids = @_bulk_loading_extracted_ids
718
+ else
719
+ ids = pluck(bulk_options[:foreign_key]).compact.uniq
720
+ @_bulk_loading_extracted_ids = ids
721
+ end
722
+
723
+ # Skip if no IDs and no remote conditions
724
+ return if ids.empty?
725
+ end
726
+
727
+ # Call the bulk method
728
+ bulk_method = bulk_options[:bulk_method]
729
+
730
+ # Get the model class to call the bulk method
731
+ model_class = klass
732
+ bulk_data = model_class.send(bulk_method, ids, @_bulk_loading_query_options)
733
+
734
+ # Create lookup hash from bulk data
735
+ lookup = {}
736
+ bulk_data.each do |item|
737
+ lookup[item.id.to_s] = item if item.respond_to?(:id)
738
+ end
739
+
740
+ # Store the lookup using a hash-based cache to avoid collisions
741
+ cache_key = @_bulk_loading_cache_key
742
+
743
+ # Initialize the cache hash if it doesn't exist
744
+ cache_hash = model_class.instance_variable_get(:@_bulk_loading_cache) || {}
745
+ cache_hash[cache_key] = {
746
+ lookup: lookup,
747
+ attributes: @_bulk_loading_attributes,
748
+ options: bulk_options
749
+ }
750
+ model_class.instance_variable_set(:@_bulk_loading_cache, cache_hash)
751
+
752
+ # Store the current cache key in a thread-local variable so after_find can access it
753
+ Thread.current[:active_rpc_current_cache_key] = cache_key
754
+
755
+ # Mark as performed to avoid duplicate calls
756
+ @_bulk_loading_performed = true
757
+ end
758
+ relation
759
+ end
760
+
761
+ # Define a method to directly access the gRPC client for custom calls
762
+ client_method_name = "#{resource.to_s.underscore}_client".to_sym
763
+ define_singleton_method(client_method_name) do
764
+ grpc_client
765
+ end
766
+
767
+ # We don't need a separate method to check service availability
768
+ # Instead, we'll handle connection errors directly in the create/update methods
769
+
770
+ # Define a class method for creation (for backward compatibility with tests)
771
+ define_singleton_method("create_#{resource.to_s.underscore}") do |attributes = {}|
772
+ # Create a new instance
773
+ instance = new
774
+
775
+ # Build the resource
776
+ instance.send("build_#{resource.to_s.underscore}", attributes)
777
+
778
+ # Create and associate the resource
779
+ return unless instance.send("create_and_associate_#{resource.to_s.underscore}", attributes)
780
+
781
+ # Return the ID
782
+ instance.send(options[:foreign_key])
783
+ end
784
+
785
+ # Define method to build a new resource without saving it
786
+ define_method("build_#{resource.to_s.underscore}") do |attributes = {}|
787
+ # Set any attributes that match our delegated attributes
788
+ attributes.each do |attr, value|
789
+ attr_sym = attr.to_sym
790
+
791
+ # Check if this is a remote attribute
792
+ next unless self.class.active_rpc_config[:attributes].include?(attr_sym)
793
+
794
+ # Track the change using ActiveModel::Dirty
795
+ send("#{attr}_will_change!") if respond_to?("#{attr}_will_change!")
796
+
797
+ # Set the attribute value
798
+ send("#{attr}=", value)
799
+ end
800
+
801
+ # Return self for method chaining
802
+ self
803
+ end
804
+
805
+ # Define an instance method to make custom RPC calls
806
+ custom_call_method = "call_#{resource.to_s.underscore}_rpc".to_sym
807
+ define_method(custom_call_method) do |method_name, params = {}|
808
+ # Add field tracking metadata for custom RPC calls with parameters
809
+ if params.is_a?(Hash) && !params.empty?
810
+ params_with_fields = params.to_h
811
+ params_with_fields[:_fields] = ActiveRpc::ModelExtensions._active_rpc_extract_field_paths(params)
812
+ self.class.grpc_call_with_locale(method_name, params_with_fields)
813
+ else
814
+ self.class.grpc_call_with_locale(method_name, params)
815
+ end
816
+ rescue StandardError => e
817
+ Rails.logger.error("Failed to call custom RPC method #{method_name} with params #{params}: #{e.message}")
818
+ nil
819
+ end
820
+
821
+ # Define method to save pending updates to the gRPC service
822
+ update_method = "update_#{resource.to_s.underscore}".to_sym
823
+ define_method(update_method) do
824
+ # Check if there are any changed attributes
825
+ if send("has_#{resource.to_s.underscore}_updates?")
826
+ foreign_key_value = send(options[:foreign_key])
827
+
828
+ # Return false if foreign key is not present
829
+ unless foreign_key_value.present?
830
+ Rails.logger.error("[ActiveRpc] Cannot update #{resource} without a valid #{options[:foreign_key]}")
831
+ errors.add(options[:foreign_key], "Cannot update #{resource.to_s.underscore} without a valid ID")
832
+ return false
833
+ end
834
+
835
+ # Prepare parameters for the update call
836
+ formatted_id = ActiveRpc::ModelExtensions.format_foreign_key_value(self.class, options[:foreign_key],
837
+ foreign_key_value)
838
+ params = { id: formatted_id }
839
+
840
+ # DEBUG: Log the formatted ID
841
+ Rails.logger.debug("[ActiveRpc DEBUG] Foreign key #{options[:foreign_key]}: #{foreign_key_value.inspect} (#{foreign_key_value.class}) -> formatted: #{formatted_id.inspect} (#{formatted_id.class})")
842
+
843
+ # Add all changed attributes to the params
844
+ pending_updates = send("pending_#{resource.to_s.underscore}_updates")
845
+ params.merge!(pending_updates)
846
+
847
+ Rails.logger.info("[ActiveRpc] Updating #{resource} with ID #{foreign_key_value} with params: #{params.keys}")
848
+
849
+ # Call the UpdateUser method on the gRPC service
850
+ begin
851
+ # Add field tracking metadata to help with protobuf field presence
852
+ params_with_fields = params.to_h
853
+ params_with_fields[:_fields] = ActiveRpc::ModelExtensions._active_rpc_extract_field_paths(params)
854
+
855
+ response = self.class.grpc_call_with_locale("Update#{resource}".to_sym, params_with_fields)
856
+
857
+ # Check if the update was successful by looking at _metadata first
858
+ if response&.message&.respond_to?(:_metadata) && response.message._metadata&.success == false
859
+ # Handle validation errors from _metadata
860
+ if response.message._metadata.errors && response.message._metadata.errors.length > 0
861
+ response.message._metadata.errors.each do |field, error_messages|
862
+ messages =
863
+ if error_messages.respond_to?(:messages)
864
+ error_messages.messages
865
+ elsif error_messages.is_a?(Array)
866
+ error_messages
867
+ else
868
+ Array(error_messages)
869
+ end
870
+
871
+ messages.each { |message| errors.add(field.to_sym, message) }
872
+ end
873
+ else
874
+ errors.add(:base, 'Validation failed')
875
+ end
876
+ false
877
+ elsif response&.message
878
+ # Update attributes with the response data
879
+ if response.message.respond_to?(:to_h)
880
+ # Convert the response message to a hash
881
+ response_data = response.message.to_h
882
+
883
+ # Use intersection to get only the attributes that exist in both the response and our model
884
+ attrs_to_assign = response_data.slice(*self.class.active_rpc_config[:attributes].map(&:to_sym))
885
+
886
+ # Assign the attributes in one go
887
+ assign_attributes(attrs_to_assign)
888
+ end
889
+
890
+ clear_attribute_changes(rpc_attribute_names) if respond_to?(:clear_attribute_changes)
891
+
892
+ Rails.logger.info("[ActiveRpc] Successfully updated #{resource} with ID #{foreign_key_value}")
893
+ true
894
+ else
895
+ # Handle server error response
896
+ error_message = 'No response message'
897
+
898
+ # Extract error details if available
899
+ if response&.error
900
+ error_code = response.error.respond_to?(:code) ? response.error.code : 'UNKNOWN'
901
+ error_details = response.error.respond_to?(:message) ? response.error.message : 'Unknown error'
902
+ error_message = "Server returned error: #{error_code} - #{error_details}"
903
+
904
+ # Add specific error messages to the model
905
+ # Clean up the error message - gRPC adds some debug info
906
+ clean_error = error_details.gsub(/\. debug_error_string:.+$/, '')
907
+
908
+ # Check if the error response has structured validation_errors
909
+ if response.error.respond_to?(:validation_errors) && response.error.validation_errors.present?
910
+ # Get the validation errors
911
+ validation_errors = response.error.validation_errors
912
+
913
+ # Add each validation error to the model
914
+ validation_errors.each do |field, messages|
915
+ field_sym = field.to_sym
916
+
917
+ # Convert messages to array if it's not already
918
+ messages_array = messages.is_a?(Array) ? messages : [ messages ]
919
+
920
+ # Add each message to the field
921
+ messages_array.each do |message|
922
+ errors.add(field_sym, message)
923
+ end
924
+ end
925
+ # Check if the error response has full_messages
926
+ elsif response.error.respond_to?(:full_messages) && response.error.full_messages.present?
927
+ # Add each full message to the model
928
+ response.error.full_messages.each do |message|
929
+ # Try to parse the message to extract field and error
930
+ if message =~ /^([A-Za-z_]+) (is .+)$/
931
+ field = ::Regexp.last_match(1).downcase.to_sym
932
+ error_msg = ::Regexp.last_match(2)
933
+ errors.add(field, error_msg)
934
+ else
935
+ errors.add(options[:foreign_key], message)
936
+ end
937
+ end
938
+ # Check for validation errors in the error message
939
+ elsif clean_error.include?('Validation failed:')
940
+ # Extract the validation errors part
941
+ validation_part = clean_error.gsub(/^\d+:/, '').strip
942
+ validation_errors = validation_part.gsub('Validation failed: ', '').split(', ')
943
+
944
+ validation_errors.each do |error|
945
+ # Parse errors in format "Field is invalid"
946
+ if error =~ /^([A-Za-z_]+) (is .+)$/
947
+ field = ::Regexp.last_match(1).downcase.to_sym
948
+ message = ::Regexp.last_match(2)
949
+ errors.add(field, message)
950
+ elsif error.include?(':')
951
+ field, message = error.split(':', 2)
952
+ errors.add(field.strip.to_sym, message.strip)
953
+ else
954
+ errors.add(options[:foreign_key], error.strip)
955
+ end
956
+ end
957
+ else
958
+ errors.add(options[:foreign_key],
959
+ "Failed to update #{resource.to_s.underscore} record: #{clean_error}")
960
+ end
961
+ else
962
+ errors.add(options[:foreign_key], "Failed to update #{resource.to_s.underscore} record")
963
+ end
964
+
965
+ Rails.logger.error("[ActiveRpc] Failed to update #{resource} with ID #{foreign_key_value}: #{error_message}")
966
+ false
967
+ end
968
+ rescue StandardError => e
969
+ # Handle any exceptions that might occur during the RPC call
970
+ error_message = e.message
971
+
972
+ # Check if this is a connection error
973
+ if error_message.include?('Connection refused') ||
974
+ error_message.include?('failed to connect') ||
975
+ error_message.include?('Deadline exceeded') ||
976
+ error_message.include?('UNAVAILABLE')
977
+ # This is a connection error, so the service is likely down
978
+ Rails.logger.error("[ActiveRpc] Failed to connect to #{resource} service: #{error_message}")
979
+ errors.add(options[:foreign_key], 'Unable to connect to the required service. Please try again later.')
980
+ else
981
+ # This is some other error
982
+ Rails.logger.error("[ActiveRpc] Error updating #{resource}: #{error_message}")
983
+ errors.add(options[:foreign_key],
984
+ "Failed to update #{resource.to_s.underscore} record: #{error_message}")
985
+ end
986
+
987
+ false
988
+ end
989
+ else
990
+ # No pending updates, return true
991
+ Rails.logger.debug("[ActiveRpc] No pending updates for #{resource} with ID #{send(options[:foreign_key])}")
992
+ true
993
+ end
994
+ end
995
+
996
+ # Define save method for the resource
997
+ define_method("save_#{resource.to_s.underscore}") do
998
+ if send(options[:foreign_key]).present?
999
+ # Update existing resource
1000
+ send(update_method)
1001
+ elsif attributes.any? { |attr| changed_attributes.key?(attr.to_s) }
1002
+ # Create new resource
1003
+ create_attrs = {}
1004
+ attributes.each do |attr|
1005
+ next unless changed_attributes.key?(attr.to_s)
1006
+
1007
+ type = if self.class.respond_to?(:attribute_types)
1008
+ self.class.attribute_types[attr.to_s]
1009
+ end
1010
+ create_attrs[attr] = ActiveRpc::ModelExtensions.prepare_value_for_rpc(send(attr), type)
1011
+ end
1012
+
1013
+ # Create and associate the resource
1014
+ send("create_and_associate_#{resource.to_s.underscore}", create_attrs)
1015
+ # Convert changed attributes to a hash for create
1016
+ else
1017
+ # No attributes to save
1018
+ true
1019
+ end
1020
+ end
1021
+
1022
+ # Define save! method for the resource
1023
+ define_method("save_#{resource.to_s.underscore}!") do
1024
+ result = send("save_#{resource.to_s.underscore}")
1025
+ raise "Failed to save #{resource.to_s.underscore}: #{errors.full_messages.join(', ')}" unless result
1026
+
1027
+ result
1028
+ end
1029
+
1030
+ # Define method to check if there are pending updates
1031
+ define_method("has_#{resource.to_s.underscore}_updates?".to_sym) do
1032
+ # Check if any of the remote attributes have changed using ActiveModel::Dirty
1033
+ # Use changed_attributes to avoid infinite recursion
1034
+ attributes.any? { |attr| changed_attributes.key?(attr.to_s) }
1035
+ end
1036
+
1037
+ # Define method to get changes to delegated attributes
1038
+ define_method("#{resource.to_s.underscore}_changes".to_sym) do
1039
+ changes.slice(*attributes.map(&:to_s))
1040
+ end
1041
+
1042
+ # Define method to clear pending updates without saving them
1043
+ define_method("discard_#{resource.to_s.underscore}_updates".to_sym) do
1044
+ if send("has_#{resource.to_s.underscore}_updates?")
1045
+ # Get the original values from the server
1046
+ send(options[:reload_method])
1047
+
1048
+ # Clear remote attribute changes without touching local ActiveRecord fields
1049
+ clear_attribute_changes(rpc_attribute_names) if respond_to?(:clear_attribute_changes)
1050
+
1051
+ Rails.logger.info("[ActiveRpc] Discarded pending updates for #{resource} with ID #{send(options[:foreign_key])}")
1052
+ end
1053
+ true
1054
+ end
1055
+
1056
+ # Define method to get pending updates
1057
+ define_method("pending_#{resource.to_s.underscore}_updates".to_sym) do
1058
+ changed_keys = changed_attributes.keys.intersection(attributes.map(&:to_s))
1059
+ changed_keys.each_with_object({}) do |attr_name, payload|
1060
+ type = if self.class.respond_to?(:attribute_types)
1061
+ self.class.attribute_types[attr_name.to_s]
1062
+ end
1063
+ payload[attr_name] = ActiveRpc::ModelExtensions.prepare_value_for_rpc(send(attr_name), type)
1064
+ end
1065
+ end
1066
+
1067
+ # Define method to create a new resource and associate it with this model
1068
+ define_method("create_and_associate_#{resource.to_s.underscore}".to_sym) do |attributes = {}|
1069
+ # Prepare parameters for the create call
1070
+ params = {}
1071
+ normalized_attributes =
1072
+ if attributes.respond_to?(:to_h)
1073
+ attributes.to_h
1074
+ else
1075
+ attributes || {}
1076
+ end
1077
+
1078
+ normalized_attributes =
1079
+ if normalized_attributes.respond_to?(:with_indifferent_access)
1080
+ normalized_attributes.with_indifferent_access
1081
+ else
1082
+ normalized_attributes
1083
+ end
1084
+
1085
+ # Only include attributes that are defined in the proto
1086
+ self.class.active_rpc_config[:attributes].each do |attr|
1087
+ next unless normalized_attributes.respond_to?(:key?)
1088
+ next unless normalized_attributes.key?(attr) || normalized_attributes.key?(attr.to_s)
1089
+
1090
+ value = normalized_attributes[attr] || normalized_attributes[attr.to_s]
1091
+ type = if self.class.respond_to?(:attribute_types)
1092
+ self.class.attribute_types[attr.to_s]
1093
+ end
1094
+ params[attr] = ActiveRpc::ModelExtensions.prepare_value_for_rpc(value, type)
1095
+ end
1096
+
1097
+ # Include any additional attributes that might be needed for the RPC call
1098
+ # but are not part of the model's attributes (like password, avatar_image, etc.)
1099
+ normalized_attributes.each do |key, value|
1100
+ key_sym = key.to_sym
1101
+ next if params.key?(key_sym)
1102
+
1103
+ type = if self.class.respond_to?(:attribute_types)
1104
+ self.class.attribute_types[key.to_s]
1105
+ end
1106
+ params[key_sym] = ActiveRpc::ModelExtensions.prepare_value_for_rpc(value, type)
1107
+ end
1108
+
1109
+ Rails.logger.info("[ActiveRpc] Creating new #{resource} with params: #{params.keys}")
1110
+
1111
+ # Call the CreateUser method on the gRPC service
1112
+ # Add field tracking metadata to help with protobuf field presence
1113
+ params_with_fields = params.to_h
1114
+ params_with_fields[:_fields] = ActiveRpc::ModelExtensions._active_rpc_extract_field_paths(params)
1115
+
1116
+ response = self.class.grpc_call_with_locale("Create#{resource}".to_sym, params_with_fields)
1117
+
1118
+ # Check if the creation was successful by looking at _metadata first
1119
+ if response&.message&.respond_to?(:_metadata) && response.message._metadata&.success == false
1120
+ # This is a structured error response, handle validation errors
1121
+ Rails.logger.debug('[ActiveRpc] Received structured error response')
1122
+
1123
+ # Process the structured validation errors
1124
+ validation_errors_added = false
1125
+ if response.message._metadata.errors && response.message._metadata.errors.length > 0
1126
+ response.message._metadata.errors.each do |field, error_messages|
1127
+ error_messages.messages.each { |msg| errors.add(field.to_sym, msg) }
1128
+ end
1129
+ validation_errors_added = true
1130
+ Rails.logger.debug('[ActiveRpc] Added structured validation errors')
1131
+ end
1132
+
1133
+ unless validation_errors_added
1134
+ errors.add(options[:foreign_key],
1135
+ "An error occurred while creating the #{resource.to_s.underscore} record. Please try again.")
1136
+ end
1137
+
1138
+ Rails.logger.debug('[ActiveRpc] create_and_associate result: false, foreign_key: nil')
1139
+ false
1140
+ elsif response&.message&.respond_to?(:id)
1141
+ new_id = response.message.id
1142
+ Rails.logger.info("[ActiveRpc] Successfully created #{resource} with ID #{new_id}")
1143
+
1144
+ # Set the foreign key
1145
+ send("#{options[:foreign_key]}=", new_id)
1146
+
1147
+ # Update attributes with the response data
1148
+ if response.message.respond_to?(:to_h)
1149
+ # Convert the response message to a hash
1150
+ response_data = response.message.to_h
1151
+
1152
+ # Use intersection to get only the attributes that exist in both the response and our model
1153
+ attrs_to_assign = response_data.slice(*self.class.active_rpc_config[:attributes].map(&:to_sym))
1154
+
1155
+ # Assign the attributes in one go
1156
+ assign_attributes(attrs_to_assign)
1157
+ end
1158
+
1159
+ # Clear remote attribute changes since these are now the "original" values
1160
+ clear_attribute_changes(rpc_attribute_names) if respond_to?(:clear_attribute_changes)
1161
+
1162
+ Rails.logger.debug("[ActiveRpc] create_and_associate result: true, foreign_key: #{new_id}")
1163
+ true
1164
+ else
1165
+ # Check for validation errors
1166
+ if response&.error
1167
+ error_code = response.error.respond_to?(:code) ? response.error.code : 'UNKNOWN'
1168
+ error_details = response.error.respond_to?(:message) ? response.error.message : 'Unknown error'
1169
+ error_message = "Server returned error: #{error_code} - #{error_details}"
1170
+
1171
+ # Handle validation errors - check for shared error object first
1172
+ validation_errors_added = false
1173
+
1174
+ # Check for shared error object in response metadata (legacy fallback)
1175
+ if response.respond_to?(:_metadata) && response._metadata && !response._metadata.success && response._metadata.errors && response._metadata.errors.length > 0
1176
+ response._metadata.errors.each do |field, error_messages|
1177
+ error_messages.messages.each { |msg| errors.add(field.to_sym, msg) }
1178
+ end
1179
+ validation_errors_added = true
1180
+ end
1181
+
1182
+ # If no structured errors found, add generic error
1183
+ unless validation_errors_added
1184
+ errors.add(options[:foreign_key],
1185
+ "An error occurred while creating the #{resource.to_s.underscore} record. Please try again.")
1186
+ end
1187
+
1188
+ # If no validation errors were added, add a generic error
1189
+ unless validation_errors_added
1190
+ # Check if we have a meaningful error message to show
1191
+ if error_details.present? && error_details != 'Unknown error'
1192
+ # Clean up the error message - remove gRPC debug info
1193
+ clean_error = error_details.gsub(/\. debug_error_string:.+$/, '')
1194
+ errors.add(options[:foreign_key], clean_error)
1195
+ else
1196
+ errors.add(options[:foreign_key],
1197
+ "An error occurred while creating the #{resource.to_s.underscore} record. Please try again.")
1198
+ end
1199
+ end
1200
+
1201
+ Rails.logger.error("[ActiveRpc] Failed to create #{resource}: #{error_message}")
1202
+ else
1203
+ Rails.logger.error("[ActiveRpc] Failed to create #{resource}: No valid response")
1204
+ errors.add(options[:foreign_key],
1205
+ "An error occurred while creating the #{resource.to_s.underscore} record. Please try again.")
1206
+ end
1207
+
1208
+ Rails.logger.debug('[ActiveRpc] create_and_associate result: false, foreign_key: nil')
1209
+ false
1210
+ end
1211
+ rescue StandardError => e
1212
+ # Handle any exceptions that might occur during the RPC call
1213
+ error_message = e.message
1214
+
1215
+ # Check if this is a connection error
1216
+ if error_message.include?('Connection refused') ||
1217
+ error_message.include?('failed to connect') ||
1218
+ error_message.include?('Deadline exceeded') ||
1219
+ error_message.include?('UNAVAILABLE')
1220
+ # This is a connection error, so the service is likely down
1221
+ Rails.logger.error("[ActiveRpc] Failed to connect to #{resource} service: #{error_message}")
1222
+ errors.add(options[:foreign_key], 'Unable to connect to the required service. Please try again later.')
1223
+ else
1224
+ # Add generic error for any other exceptions
1225
+ Rails.logger.error("[ActiveRpc] Error creating #{resource}: #{error_message}")
1226
+ errors.add(options[:foreign_key],
1227
+ "An error occurred while creating the #{resource.to_s.underscore} record. Please try again.")
1228
+ end
1229
+
1230
+ # Return false to indicate failure
1231
+ false
1232
+ end
1233
+
1234
+ # Define method to get query configuration
1235
+ define_singleton_method("#{resource.to_s.underscore}_query_config") do
1236
+ active_rpc_config[:query_config] || {}
1237
+ end
1238
+
1239
+ # Define method to get available scopes
1240
+ define_singleton_method("#{resource.to_s.underscore}_scopes") do
1241
+ active_rpc_config[:scopes] || {}
1242
+ end
1243
+
1244
+ # Define class method for searching with query parameters
1245
+ define_singleton_method("search_#{resource.to_s.pluralize.underscore}") do |query_options = {}|
1246
+ # Prepare parameters for the search call
1247
+ params = {}
1248
+
1249
+ # Add search parameters if provided
1250
+ params[:q] = query_options[:q] if query_options[:q].present?
1251
+
1252
+ # Add scope parameters if provided
1253
+ params[:scope] = query_options[:scope] if query_options[:scope].present?
1254
+
1255
+ # Add include parameters if provided
1256
+ params[:include] = query_options[:include] if query_options[:include].present?
1257
+
1258
+ # Add pagination parameters ONLY if explicitly provided
1259
+ if query_options[:page].present? && query_options[:per_page].present?
1260
+ params[:page] = query_options[:page]
1261
+ params[:per_page] = query_options[:per_page]
1262
+ end
1263
+
1264
+ # Add sort parameters if provided
1265
+ params[:sort] = query_options[:sort] if query_options[:sort].present?
1266
+
1267
+ # Call the List method on the gRPC service
1268
+ # Add field tracking metadata to help with protobuf field presence
1269
+ params_with_fields = params.to_h
1270
+ params_with_fields[:_fields] = ActiveRpc::ModelExtensions._active_rpc_extract_field_paths(params)
1271
+
1272
+ response = grpc_call_with_locale("List#{resource}s", params_with_fields)
1273
+
1274
+ # Return the items from the response
1275
+ response&.message&.respond_to?(:items) ? response.message.items : []
1276
+ rescue StandardError => e
1277
+ Rails.logger.error("Failed to search #{resource} data: #{e.message}")
1278
+ []
1279
+ end
1280
+
1281
+ # Update the bulk method to support query parameters
1282
+ define_singleton_method(options[:bulk_method]) do |ids = [], query_options = {}|
1283
+ if defined?(Rails)
1284
+ Rails.logger.debug("[ActiveRpc] #{options[:bulk_method]} called with IDs: #{ids.size} items, query_options: #{query_options.keys}")
1285
+ end
1286
+
1287
+ # Don't return early if we have query options (scope, where, etc.)
1288
+ # This allows filtering without specific IDs
1289
+ has_query_conditions = query_options.present? && (
1290
+ query_options[:scope].present? ||
1291
+ query_options[:where].present? ||
1292
+ query_options[:q].present?
1293
+ )
1294
+
1295
+ return [] if ids.empty? && !has_query_conditions
1296
+
1297
+ begin
1298
+ # Prepare parameters for the bulk call
1299
+ # Format IDs based on the foreign key column type
1300
+ formatted_ids = ids.map do |id|
1301
+ ActiveRpc::ModelExtensions.format_foreign_key_value(self, options[:foreign_key], id)
1302
+ end
1303
+ params = { ids: formatted_ids }
1304
+ Rails.logger.debug("[ActiveRpc] gRPC params prepared with #{formatted_ids.size} IDs") if defined?(Rails)
1305
+
1306
+ # Add search parameters if provided
1307
+ params[:q] = query_options[:q] if query_options[:q].present?
1308
+
1309
+ # Add ActiveRecord-style where filters if provided
1310
+ params[:where] = query_options[:where] if query_options[:where].present?
1311
+
1312
+ # Add scope parameters if provided
1313
+ if query_options[:scope].present?
1314
+ # Serialize entire scope arguments as single JSON payload
1315
+ # This ensures consistent handling of all argument types (arrays, hashes, objects, etc.)
1316
+ params[:scope] = { 'args' => query_options[:scope].to_json }
1317
+ if defined?(Rails)
1318
+ Rails.logger.debug("[ActiveRpc] Serialized scope args as JSON: #{query_options[:scope].keys}")
1319
+ end
1320
+ end
1321
+
1322
+ # Add include parameters if provided
1323
+ params[:include] = query_options[:include] if query_options[:include].present?
1324
+
1325
+ # Add pagination parameters
1326
+ if query_options[:page].present? && query_options[:per_page].present?
1327
+ params[:page] = query_options[:page]
1328
+ params[:per_page] = query_options[:per_page]
1329
+ if defined?(Rails)
1330
+ Rails.logger.debug("[ActiveRpc] Using provided pagination: page=#{params[:page]}, per_page=#{params[:per_page]}")
1331
+ end
1332
+ elsif defined?(Rails)
1333
+ # For bulk operations, don't set per_page to get ALL results
1334
+ if defined?(Rails)
1335
+ Rails.logger.debug('[ActiveRpc] Bulk operation without pagination limit - fetching all results')
1336
+ end
1337
+ end
1338
+
1339
+ # Add sort parameters if provided
1340
+ params[:sort] = query_options[:sort] if query_options[:sort].present?
1341
+
1342
+ # Call the List method on the gRPC service
1343
+ # Add field tracking metadata to help with protobuf field presence
1344
+ params_with_fields = params.to_h
1345
+ params_with_fields[:_fields] = ActiveRpc::ModelExtensions._active_rpc_extract_field_paths(params)
1346
+
1347
+ Rails.logger.debug("[ActiveRpc] Making gRPC call: List#{resource}s") if defined?(Rails)
1348
+
1349
+ begin
1350
+ response = grpc_call_with_locale("List#{resource}s", params_with_fields)
1351
+
1352
+ if response.nil?
1353
+ Rails.logger.error('[ActiveRpc] gRPC response is nil') if defined?(Rails)
1354
+ else
1355
+ item_count = if response&.message.respond_to?(:users)
1356
+ response.message.users.size
1357
+ elsif response&.message.respond_to?(:items)
1358
+ response.message.items.size
1359
+ else
1360
+ 0
1361
+ end
1362
+ if defined?(Rails)
1363
+ Rails.logger.debug("[ActiveRpc] gRPC call completed successfully, received #{item_count} items")
1364
+ end
1365
+ end
1366
+ rescue StandardError => e
1367
+ Rails.logger.error("[ActiveRpc] gRPC call failed: #{e.class}: #{e.message}") if defined?(Rails)
1368
+ raise e
1369
+ end
1370
+
1371
+ # Return the items from the response
1372
+ result = if response&.message&.respond_to?(:users)
1373
+ response.message.users
1374
+ elsif response&.message&.respond_to?(:items)
1375
+ response.message.items
1376
+ else
1377
+ Rails.logger.warn('[ActiveRpc] No users or items found in gRPC response') if defined?(Rails)
1378
+ []
1379
+ end
1380
+
1381
+ Rails.logger.debug("[ActiveRpc] Returning #{result.size} items from gRPC call") if defined?(Rails)
1382
+ result
1383
+ rescue StandardError => e
1384
+ Rails.logger.error("Failed to fetch bulk #{resource} data for ids=#{ids}: #{e.message}")
1385
+ []
1386
+ end
1387
+ end
1388
+ end
1389
+ end
1390
+
1391
+ # Module-level method to extract all field paths from a nested hash structure
1392
+ # Examples:
1393
+ # {id: "22", user_roles_list: []} -> ["id", "user_roles_list"]
1394
+ # {id: "22", avatar_attributes: {filename: "test.jpg"}} -> ["id", "avatar_attributes", "avatar_attributes.filename"]
1395
+ # {user_roles_list: [{id: "1", role_id: "admin"}]} -> ["user_roles_list", "user_roles_list[0]", "user_roles_list[0].id", "user_roles_list[0].role_id"]
1396
+ def self._active_rpc_extract_field_paths(params, prefix = '')
1397
+ return [] unless params.respond_to?(:each)
1398
+
1399
+ field_paths = []
1400
+
1401
+ params.each do |key, value|
1402
+ # Skip internal fields (starting with _)
1403
+ next if key.to_s.start_with?('_')
1404
+
1405
+ current_path = prefix.empty? ? key.to_s : "#{prefix}.#{key}"
1406
+
1407
+ # Always include the current field path
1408
+ field_paths << current_path
1409
+
1410
+ if value.is_a?(Hash) && value.any?
1411
+ # Preserve original dot notation for nested hashes
1412
+ field_paths.concat(_active_rpc_extract_field_paths(value, current_path))
1413
+
1414
+ # Also emit bracket notation so protobuf map fields keep key-level metadata
1415
+ value.each do |map_key, map_value|
1416
+ map_path = "#{current_path}[#{map_key}]"
1417
+ field_paths << map_path
1418
+ field_paths.concat(_active_rpc_extract_field_paths(map_value, map_path))
1419
+ end
1420
+ next
1421
+ end
1422
+
1423
+ # Handle arrays with hash elements (like user_roles_list)
1424
+ next unless value.is_a?(Array)
1425
+
1426
+ # Include the array field itself even if empty
1427
+ value.each_with_index do |item, index|
1428
+ next unless item.is_a?(Hash)
1429
+
1430
+ item_path = "#{current_path}[#{index}]"
1431
+ field_paths << item_path
1432
+ field_paths.concat(_active_rpc_extract_field_paths(item, item_path))
1433
+ end
1434
+ end
1435
+
1436
+ field_paths.uniq
1437
+ end
1438
+
1439
+ # Helper method to format foreign key values based on database column type
1440
+ # This ensures compatibility between ActiveRecord column types and protobuf field types
1441
+ def self.format_foreign_key_value(model_class, foreign_key, value)
1442
+ return nil if value.blank?
1443
+
1444
+ # Get the column type from the model's database schema
1445
+ column = model_class.columns_hash[foreign_key.to_s]
1446
+
1447
+ if %i[integer bigint].include?(column&.type)
1448
+ # Send as integer for integer/bigint columns to match protobuf int64 fields
1449
+ value.to_i
1450
+ else
1451
+ # Send as string for all other types (string, uuid, etc.)
1452
+ value.to_s
1453
+ end
1454
+ end
1455
+ end
1456
+ end