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,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRpc
4
+ module Rpc
5
+ # Base controller class that includes all our concerns
6
+ class BaseController < ::Gruf::Controllers::Base
7
+ include ActiveRpc::Rpc::Concerns::RequestProcessor
8
+ include ActiveRpc::Rpc::Concerns::QueryBuilder
9
+ include ActiveRpc::Rpc::Concerns::ResourceController
10
+ include ActiveRpc::Rpc::Concerns::Serializable
11
+
12
+ # Add common methods for all gRPC controllers here
13
+
14
+ private
15
+
16
+ # Provide a params method that Pagy can use
17
+ # Converts the protobuf request into a Rails-style params hash
18
+ def params
19
+ @params ||= build_params_from_request
20
+ end
21
+
22
+ def build_params_from_request
23
+ return {} unless request&.message
24
+
25
+ request_message = request.message
26
+ params_hash = {}
27
+
28
+ # Extract all supported parameters
29
+ extract_pagination_params(request_message, params_hash)
30
+ extract_query_params(request_message, params_hash)
31
+ extract_other_params(request_message, params_hash)
32
+
33
+ params_hash
34
+ end
35
+
36
+ def extract_pagination_params(request_message, params_hash)
37
+ params_hash[:page] = request_message.page if request_message.respond_to?(:page)
38
+ params_hash[:per_page] = request_message.per_page if request_message.respond_to?(:per_page)
39
+ end
40
+
41
+ def extract_query_params(request_message, params_hash)
42
+ if request_message.respond_to?(:q) && request_message.q
43
+ params_hash[:q] = convert_protobuf_value(request_message.q)
44
+ end
45
+
46
+ return unless request_message.respond_to?(:scope) && request_message.scope
47
+
48
+ params_hash[:scope] = convert_protobuf_value(request_message.scope)
49
+ end
50
+
51
+ def extract_other_params(request_message, params_hash)
52
+ params_hash[:sort] = request_message.sort if request_message.respond_to?(:sort)
53
+ params_hash[:include] = request_message.include if request_message.respond_to?(:include)
54
+ params_hash[:ids] = request_message.ids if request_message.respond_to?(:ids) && request_message.ids
55
+ params_hash[:where] = request_message.where.to_h if request_message.respond_to?(:where) && request_message.where
56
+ end
57
+
58
+ def convert_protobuf_value(value)
59
+ value.respond_to?(:to_h) ? value.to_h : value
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,68 @@
1
+ module ActiveRpc
2
+ module Rpc
3
+ module Concerns
4
+ # The Includable concern provides methods for applying includes (eager loading)
5
+ # to ActiveRecord queries in gRPC controllers.
6
+ #
7
+ # @example
8
+ # def list_users
9
+ # process_request do
10
+ # base_query = User.all
11
+ # query = apply_includes(base_query, request.message)
12
+ # # ...
13
+ # end
14
+ # end
15
+ module Includable
16
+ extend ActiveSupport::Concern
17
+
18
+ # Apply includes to a query
19
+ def apply_includes(query, params)
20
+ return query unless params.respond_to?(:include) && params.include.present?
21
+
22
+ begin
23
+ # Convert the array to symbols
24
+ includes = params.include.map(&:to_sym)
25
+
26
+ # Get query configuration
27
+ query_config = get_query_config(query.model)
28
+ includable_assocs = query_config[:includable] || []
29
+
30
+ # Filter out non-includable associations if we have defined includable associations
31
+ filtered_includes = if includable_assocs.empty?
32
+ includes
33
+ else
34
+ includes.select do |assoc|
35
+ includable_assocs.include?(assoc)
36
+ end
37
+ end
38
+
39
+ # Apply includes
40
+ query.includes(filtered_includes)
41
+ rescue StandardError => e
42
+ Rails.logger.error("Error applying includes: #{e.message}")
43
+ # Return original query if includes fails
44
+ query
45
+ end
46
+ end
47
+
48
+ # Get query configuration for a model
49
+ def get_query_config(model_class)
50
+ if model_class.respond_to?(:active_rpc_config) && model_class.active_rpc_config
51
+ resource = model_class.active_rpc_config[:resource].to_s.underscore
52
+ query_config_method = "#{resource}_query_config"
53
+
54
+ return model_class.send(query_config_method) if model_class.respond_to?(query_config_method)
55
+ end
56
+
57
+ # Default configuration if none is defined
58
+ {
59
+ searchable: model_class.column_names,
60
+ filterable: model_class.column_names,
61
+ sortable: model_class.column_names,
62
+ includable: model_class.reflect_on_all_associations.map(&:name)
63
+ }
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,155 @@
1
+ require 'pagy'
2
+ require 'pagy/classes/request'
3
+ require 'pagy/toolbox/paginators/offset'
4
+
5
+ module ActiveRpc
6
+ module Rpc
7
+ module Concerns
8
+ # The Paginatable concern provides methods for applying pagination
9
+ # to ActiveRecord queries in gRPC controllers.
10
+ #
11
+ # It supports:
12
+ # - Standard pagination with page and per_page parameters
13
+ # - Integration with the Pagy gem if available
14
+ # - Pagination metadata for responses
15
+ #
16
+ # @example
17
+ # def list_users
18
+ # process_request do
19
+ # base_query = User.all
20
+ # query = apply_pagination(base_query, request.message)
21
+ # # ...
22
+ # end
23
+ # end
24
+ module Paginatable
25
+ extend ActiveSupport::Concern
26
+
27
+ # Apply pagination to a query only if explicitly requested
28
+ def apply_pagination(query, params)
29
+ return query unless pagination_params?(params)
30
+
31
+ begin
32
+ page, per_page = sanitized_page_params(params)
33
+ base_options = defined?(Pagy::OPTIONS) ? Pagy::OPTIONS : Pagy::DEFAULT
34
+ options = base_options.merge(rpc_pagy_options(page, per_page))
35
+ pagy, records = Pagy::OffsetPaginator.paginate(query, options)
36
+ @last_pagy = pagy
37
+ records
38
+ rescue StandardError => e
39
+ Rails.logger.error("Error applying pagination: #{e.message}")
40
+ # Return original query if pagination fails
41
+ query
42
+ end
43
+ end
44
+
45
+ # Get pagination metadata
46
+ def pagination_metadata(query, params, total_count = nil)
47
+ # If Pagy was used, get metadata from it
48
+ if defined?(@last_pagy) && @last_pagy
49
+ return {
50
+ total_count: @last_pagy.count,
51
+ page: @last_pagy.page,
52
+ per_page: @last_pagy.limit,
53
+ total_pages: @last_pagy.last
54
+ }
55
+ end
56
+
57
+ # If pagination is not requested, return metadata with all records
58
+ unless params.respond_to?(:page) && params.respond_to?(:per_page) &&
59
+ params.page.present? && params.per_page.present?
60
+ total_count = query.count
61
+ return {
62
+ total_count: total_count,
63
+ page: 1,
64
+ per_page: total_count,
65
+ total_pages: 1
66
+ }
67
+ end
68
+
69
+ # Calculate total count and pages
70
+ total_count ||= query.except(:limit, :offset).count
71
+
72
+ # Validate and sanitize per_page parameter
73
+ per_page = params.per_page.to_i
74
+ if per_page <= 0
75
+ Rails.logger.warn("Invalid per_page value: #{params.per_page}. Using default value.")
76
+ per_page = ActiveRpc::Rpc.configuration.default_per_page || 20
77
+ end
78
+
79
+ # Ensure per_page doesn't exceed maximum allowed
80
+ max_per_page = ActiveRpc::Rpc.configuration.max_per_page || 100
81
+ per_page = [ per_page, max_per_page ].min
82
+
83
+ # Calculate total pages safely
84
+ total_pages = total_count > 0 ? (total_count.to_f / per_page).ceil : 1
85
+
86
+ {
87
+ total_count: total_count,
88
+ page: params.page,
89
+ per_page: per_page,
90
+ total_pages: total_pages
91
+ }
92
+ end
93
+
94
+ private
95
+
96
+ def pagination_params?(params)
97
+ params.respond_to?(:page) && params.respond_to?(:per_page) &&
98
+ params.page.present? && params.per_page.present?
99
+ end
100
+
101
+ def sanitized_page_params(params)
102
+ page = [ params.page.to_i, 1 ].max
103
+ per_page = [ [ params.per_page.to_i, 1 ].max, ActiveRpc::Rpc.configuration.max_per_page ].min
104
+ [page, per_page]
105
+ end
106
+
107
+ def rpc_pagy_options(page, per_page)
108
+ page_key = (Pagy::OPTIONS[:page_key] || Pagy::DEFAULT[:page_key]).to_s
109
+ limit_key = (Pagy::OPTIONS[:limit_key] || Pagy::DEFAULT[:limit_key]).to_s
110
+ client_max = ActiveRpc::Rpc.configuration.max_per_page || Pagy::OPTIONS[:client_max_limit] || Pagy::DEFAULT[:limit]
111
+
112
+ request_payload = {
113
+ base_url: '',
114
+ path: '',
115
+ params: {
116
+ 'page' => {
117
+ page_key => page,
118
+ limit_key => per_page
119
+ }
120
+ },
121
+ cookie: nil
122
+ }
123
+
124
+ request_wrapper = if Pagy::Request.instance_method(:initialize).parameters.any? { |type, _| type == :key || type == :keyreq }
125
+ Pagy::Request.new(
126
+ request: request_payload,
127
+ root_key: 'page',
128
+ page_key: page_key,
129
+ limit_key: limit_key,
130
+ client_max_limit: client_max,
131
+ jsonapi: true
132
+ )
133
+ else
134
+ Pagy::Request.new(
135
+ request_payload.transform_keys { |key| key == :params ? :query : key },
136
+ jsonapi: true,
137
+ page_key: page_key,
138
+ limit_key: limit_key,
139
+ client_max_limit: client_max
140
+ )
141
+ end
142
+
143
+ {
144
+ request: request_wrapper,
145
+ jsonapi: true,
146
+ client_max_limit: client_max,
147
+ page_key: page_key,
148
+ limit_key: limit_key,
149
+ limit: per_page
150
+ }
151
+ end
152
+ end
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,178 @@
1
+ module ActiveRpc
2
+ module Rpc
3
+ module Concerns
4
+ # The QueryBuilder concern combines all query-related concerns and provides
5
+ # high-level methods for building and executing queries in gRPC controllers.
6
+ #
7
+ # @example
8
+ # def list_users
9
+ # process_request do
10
+ # execute_query(User, request.message,
11
+ # response_class: ::Core::UserListResponse,
12
+ # transformer: :to_rpc_response
13
+ # )
14
+ # end
15
+ # end
16
+ module QueryBuilder
17
+ extend ActiveSupport::Concern
18
+
19
+ include ActiveRpc::Rpc::Concerns::Ransackable
20
+ include ActiveRpc::Rpc::Concerns::Scopable
21
+ include ActiveRpc::Rpc::Concerns::Includable
22
+ include ActiveRpc::Rpc::Concerns::Paginatable
23
+ include ActiveRpc::Rpc::Concerns::Sortable
24
+
25
+ # Build a query with all parameters
26
+ def build_query(base_query, params)
27
+ query = base_query
28
+
29
+ # Apply filters in a specific order
30
+ query = apply_where_filters(query, params)
31
+ query = apply_ransack(query, params)
32
+ query = apply_scopes(query, params)
33
+ query = apply_includes(query, params)
34
+ apply_sorting(query, params)
35
+
36
+ # TEMPORARILY DISABLE PAGINATION TO TEST BULK LOADING
37
+ # Apply pagination last
38
+ # query = apply_pagination(query, params)
39
+ end
40
+
41
+ # Execute a query and return results with pagination metadata
42
+ def execute_query(query_or_class, request_payload, options = {})
43
+ # Determine the base query
44
+ base_query = if query_or_class.is_a?(Class)
45
+ # If a class is provided, start with all records
46
+ options[:base_query] || query_or_class.all
47
+ else
48
+ # If a query is provided, use it directly
49
+ query_or_class
50
+ end
51
+
52
+ # Filter by specific IDs if provided
53
+ if request_payload.respond_to?(:ids) && request_payload.ids.present?
54
+ Rails.logger.info("[DEBUG] IDs found: #{request_payload.ids.inspect} (#{request_payload.ids.class})")
55
+
56
+ # Convert Google::Protobuf::RepeatedField to Ruby Array
57
+ ids_array = if request_payload.ids.is_a?(Google::Protobuf::RepeatedField)
58
+ request_payload.ids.to_a
59
+ else
60
+ request_payload.ids
61
+ end
62
+
63
+ Rails.logger.info("[DEBUG] Converted IDs: #{ids_array.inspect} (#{ids_array.class})")
64
+ base_query = base_query.where(id: ids_array)
65
+ else
66
+ Rails.logger.info("[DEBUG] No IDs found. respond_to?(:ids): #{request_payload.respond_to?(:ids)}, ids.present?: #{request_payload.respond_to?(:ids) ? request_payload.ids.present? : 'N/A'}")
67
+ Rails.logger.info("[DEBUG] Request payload: #{request_payload.inspect}")
68
+ end
69
+
70
+ # Apply additional filters if provided
71
+ base_query = options[:additional_filters].call(base_query) if options[:additional_filters].is_a?(Proc)
72
+
73
+ # Apply includes from options if provided
74
+ # Note: includes are applied BEFORE scopes to avoid conflicts
75
+ base_query = base_query.includes(options[:includes]) if options[:includes].present?
76
+
77
+ # Build the query with all parameters
78
+ query = build_query(base_query, request_payload)
79
+
80
+ # Execute the query
81
+ records = query.to_a
82
+
83
+ # Transform records if a transformer is provided
84
+ if options[:transformer].is_a?(Proc)
85
+ records = records.map(&options[:transformer])
86
+ elsif options[:transformer] == :to_rpc_response
87
+ records = records.map(&:to_rpc_response)
88
+ end
89
+
90
+ # Return the response with pagination metadata
91
+ response_class = options[:response_class]
92
+ if response_class
93
+ paginated_response(response_class, records, query, request_payload, records.size)
94
+ else
95
+ {
96
+ records: records,
97
+ metadata: pagination_metadata(query, request_payload, records.size)
98
+ }
99
+ end
100
+ end
101
+
102
+ # Get the response with pagination metadata
103
+ def paginated_response(response_class, items, query, params, total_count = nil)
104
+ # Get pagination metadata
105
+ metadata = pagination_metadata(query, params, total_count)
106
+
107
+ # Check if pagination was requested
108
+ pagination_requested = params.respond_to?(:page) && params.respond_to?(:per_page) &&
109
+ params.page.present? && params.per_page.present?
110
+
111
+ # Create the response with items and pagination metadata
112
+ response_class.new(
113
+ items: items,
114
+ total_count: metadata[:total_count],
115
+ page: pagination_requested ? metadata[:page] : 1,
116
+ per_page: pagination_requested ? metadata[:per_page] : metadata[:total_count],
117
+ total_pages: pagination_requested ? metadata[:total_pages] : 1
118
+ )
119
+ end
120
+
121
+ private
122
+
123
+ # Apply ActiveRecord-style where conditions
124
+ def apply_where_filters(query, params)
125
+ return query unless params.respond_to?(:where) && params.where.present?
126
+
127
+ begin
128
+ # Convert protobuf map to hash if needed
129
+ where_conditions = params.where.respond_to?(:to_h) ? params.where.to_h : params.where
130
+
131
+ # Apply each condition
132
+ where_conditions.each do |field, value|
133
+ # Validate field is allowed (security)
134
+ if allowed_where_field?(query.model, field)
135
+ query = query.where(field => value)
136
+ else
137
+ Rails.logger.warn("Filtered out disallowed where field: #{field}")
138
+ end
139
+ end
140
+
141
+ query
142
+ rescue StandardError => e
143
+ Rails.logger.error("Error applying where filters: #{e.message}")
144
+ query
145
+ end
146
+ end
147
+
148
+ # Check if a field is allowed for where filtering
149
+ def allowed_where_field?(model_class, field)
150
+ # Get allowed fields from query config or default to column names
151
+ query_config = get_query_config(model_class)
152
+ allowed_fields = query_config[:filterable] || model_class.column_names
153
+
154
+ # Check if field is in allowed list
155
+ allowed_fields.include?(field.to_s) || allowed_fields.include?(field.to_sym)
156
+ end
157
+
158
+ # Get query configuration for a model (from Ransackable concern)
159
+ def get_query_config(model_class)
160
+ if model_class.respond_to?(:active_rpc_config) && model_class.active_rpc_config
161
+ resource = model_class.active_rpc_config[:resource].to_s.underscore
162
+ query_config_method = "#{resource}_query_config"
163
+
164
+ return model_class.send(query_config_method) if model_class.respond_to?(query_config_method)
165
+ end
166
+
167
+ # Default configuration if none is defined
168
+ {
169
+ searchable: model_class.column_names,
170
+ filterable: model_class.column_names,
171
+ sortable: model_class.column_names,
172
+ includable: model_class.reflect_on_all_associations.map(&:name)
173
+ }
174
+ end
175
+ end
176
+ end
177
+ end
178
+ end
@@ -0,0 +1,113 @@
1
+ module ActiveRpc
2
+ module Rpc
3
+ module Concerns
4
+ # The Ransackable concern provides methods for applying Ransack-based filtering
5
+ # to ActiveRecord queries in gRPC controllers.
6
+ #
7
+ # It supports:
8
+ # - Standard Ransack predicates (e.g., q[name_cont]=John)
9
+ # - Full-text search via q[search]=query
10
+ #
11
+ # @example
12
+ # def list_users
13
+ # process_request do
14
+ # base_query = User.all
15
+ # query = apply_ransack(base_query, request.message)
16
+ # # ...
17
+ # end
18
+ # end
19
+ module Ransackable
20
+ extend ActiveSupport::Concern
21
+
22
+ # Apply Ransack search parameters to a query
23
+ def apply_ransack(query, params)
24
+ return query unless params.respond_to?(:q) && params.q.present?
25
+
26
+ begin
27
+ # Convert the map to a hash
28
+ search_params = params.q.to_h
29
+
30
+ # Get query configuration
31
+ query_config = get_query_config(query.model)
32
+ searchable_attrs = query_config[:searchable] || []
33
+
34
+ # Filter out non-searchable attributes
35
+ filtered_params = {}
36
+ search_params.each do |key, value|
37
+ # Extract the attribute name from the Ransack predicate
38
+ attr_name = key.to_s.split('_').first
39
+
40
+ # Include the parameter if the attribute is searchable
41
+ if searchable_attrs.include?(attr_name.to_sym) || searchable_attrs.include?(attr_name.to_s) || searchable_attrs.empty?
42
+ filtered_params[key] = value
43
+ end
44
+ end
45
+
46
+ # Check for special search parameter
47
+ if ActiveRpc::Rpc.configuration.enable_full_text_search &&
48
+ filtered_params.key?('search') && filtered_params['search'].present?
49
+ search_query = filtered_params.delete('search')
50
+ query = apply_full_text_search(query, search_query)
51
+ end
52
+
53
+ # Apply standard Ransack filters
54
+ query.ransack(filtered_params).result
55
+ rescue => e
56
+ Rails.logger.error("Error applying ransack: #{e.message}")
57
+ # Return original query if ransack fails
58
+ query
59
+ end
60
+ end
61
+
62
+ private
63
+
64
+ # Apply full-text search to a query
65
+ def apply_full_text_search(query, search_query)
66
+ resource_class = query.model
67
+
68
+ # Try different search method patterns in order of preference
69
+ resource_name = resource_class.name.underscore.pluralize
70
+
71
+ # 1. Try model-specific search method
72
+ specific_search_method = "search_#{resource_name}"
73
+ if resource_class.respond_to?(specific_search_method)
74
+ return resource_class.send(specific_search_method, search_query)
75
+ end
76
+
77
+ # 2. Try generic search method
78
+ if resource_class.respond_to?(:search)
79
+ return resource_class.search(search_query)
80
+ end
81
+
82
+ # 3. Try full_text_search method
83
+ if resource_class.respond_to?(:full_text_search)
84
+ return resource_class.full_text_search(search_query)
85
+ end
86
+
87
+ # If no search method is found, return the original query
88
+ query
89
+ end
90
+
91
+ # Get query configuration for a model
92
+ def get_query_config(model_class)
93
+ if model_class.respond_to?(:active_rpc_config) && model_class.active_rpc_config
94
+ resource = model_class.active_rpc_config[:resource].to_s.underscore
95
+ query_config_method = "#{resource}_query_config"
96
+
97
+ if model_class.respond_to?(query_config_method)
98
+ return model_class.send(query_config_method)
99
+ end
100
+ end
101
+
102
+ # Default configuration if none is defined
103
+ {
104
+ searchable: model_class.column_names,
105
+ filterable: model_class.column_names,
106
+ sortable: model_class.column_names,
107
+ includable: model_class.reflect_on_all_associations.map(&:name)
108
+ }
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end