propel_api 0.3.1.6 → 0.3.3

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,360 @@
1
+ # frozen_string_literal: true
2
+
3
+ # DynamicScopeGenerator
4
+ #
5
+ # Generates dynamic scopes and has_scope registrations for ActiveRecord models
6
+ # based on database column types and filter operators.
7
+ #
8
+ # This class handles the creation of both model scopes and controller has_scope
9
+ # registrations dynamically, ensuring they work together properly.
10
+ #
11
+ # Usage:
12
+ # generator = DynamicScopeGenerator.new(Team)
13
+ # generator.generate_scopes!
14
+ # generator.register_controller_scopes(MyController)
15
+ #
16
+ class DynamicScopeGenerator
17
+ attr_reader :model_class, :generated_scopes, :generated_has_scopes
18
+
19
+ def initialize(model_class)
20
+ @model_class = model_class
21
+ @generated_scopes = []
22
+ @generated_has_scopes = []
23
+ end
24
+
25
+ # Generate all scopes for the model based on its columns
26
+ def generate_scopes!
27
+ return if @generated_scopes.any?
28
+
29
+ @model_class.columns.each do |column|
30
+ generate_scopes_for_column(column)
31
+ end
32
+
33
+ # Add sorting scope
34
+ generate_sort_scope
35
+ end
36
+
37
+ # Register has_scope calls for a controller
38
+ def register_controller_scopes(controller_class)
39
+ return if @generated_has_scopes.any?
40
+
41
+ @generated_scopes.each do |scope_name|
42
+ register_has_scope(controller_class, scope_name)
43
+ end
44
+ end
45
+
46
+ private
47
+
48
+ def generate_scopes_for_column(column)
49
+ name = column.name
50
+ type = column.type
51
+ sql_type = column.sql_type_metadata.sql_type
52
+
53
+ operators = PropelFilterOperators.operators_for(type, sql_type)
54
+
55
+ operators.each do |operator|
56
+ scope_name = "#{name}_#{operator}".to_sym
57
+ generate_scope_method(scope_name, name, operator, type)
58
+ @generated_scopes << scope_name
59
+ end
60
+ end
61
+
62
+ def generate_scope_method(scope_name, field_name, operator, field_type)
63
+ return if @model_class.respond_to?(scope_name)
64
+
65
+ case operator
66
+ when "eq"
67
+ generate_eq_scope(scope_name, field_name, field_type)
68
+ when "contains"
69
+ generate_contains_scope(scope_name, field_name)
70
+ when "starts_with"
71
+ generate_starts_with_scope(scope_name, field_name)
72
+ when "ends_with"
73
+ generate_ends_with_scope(scope_name, field_name)
74
+ when "gt"
75
+ generate_gt_scope(scope_name, field_name)
76
+ when "lt"
77
+ generate_lt_scope(scope_name, field_name)
78
+ when "gte", "min"
79
+ generate_gte_scope(scope_name, field_name)
80
+ when "lte", "max"
81
+ generate_lte_scope(scope_name, field_name)
82
+ when "before"
83
+ generate_before_scope(scope_name, field_name)
84
+ when "after"
85
+ generate_after_scope(scope_name, field_name)
86
+ when "in"
87
+ generate_in_scope(scope_name, field_name)
88
+ when "null"
89
+ generate_null_scope(scope_name, field_name)
90
+ when "range"
91
+ generate_range_scope(scope_name, field_name)
92
+ when "date"
93
+ generate_date_scope(scope_name, field_name)
94
+ when "year"
95
+ generate_year_scope(scope_name, field_name)
96
+ when "month"
97
+ generate_month_scope(scope_name, field_name)
98
+ when "day"
99
+ generate_day_scope(scope_name, field_name)
100
+ end
101
+ rescue StandardError => e
102
+ Rails.logger.warn "Error generating scope #{scope_name}: #{e.message}"
103
+ end
104
+
105
+ def generate_eq_scope(scope_name, field_name, field_type)
106
+ case field_type
107
+ when :boolean
108
+ @model_class.class_eval <<-RUBY, __FILE__, __LINE__ + 1
109
+ scope :#{scope_name}, ->(value) {
110
+ return all if value.blank?
111
+ boolean_value = case value.to_s.downcase
112
+ when 'true', '1', 'yes', 'on'
113
+ true
114
+ when 'false', '0', 'no', 'off'
115
+ false
116
+ else
117
+ value
118
+ end
119
+ where(#{field_name}: boolean_value)
120
+ }
121
+ RUBY
122
+ else
123
+ @model_class.class_eval <<-RUBY, __FILE__, __LINE__ + 1
124
+ scope :#{scope_name}, ->(value) { value.present? ? where(#{field_name}: value) : all }
125
+ RUBY
126
+ end
127
+ end
128
+
129
+ def generate_contains_scope(scope_name, field_name)
130
+ @model_class.class_eval <<-RUBY, __FILE__, __LINE__ + 1
131
+ scope :#{scope_name}, ->(value) {
132
+ value.present? ? where("#{field_name} #{like_operator} ?", "%\#{value}%") : all
133
+ }
134
+ RUBY
135
+ end
136
+
137
+ def generate_starts_with_scope(scope_name, field_name)
138
+ @model_class.class_eval <<-RUBY, __FILE__, __LINE__ + 1
139
+ scope :#{scope_name}, ->(value) {
140
+ value.present? ? where("#{field_name} #{like_operator} ?", "\#{value}%") : all
141
+ }
142
+ RUBY
143
+ end
144
+
145
+ def generate_ends_with_scope(scope_name, field_name)
146
+ @model_class.class_eval <<-RUBY, __FILE__, __LINE__ + 1
147
+ scope :#{scope_name}, ->(value) {
148
+ value.present? ? where("#{field_name} #{like_operator} ?", "%\#{value}") : all
149
+ }
150
+ RUBY
151
+ end
152
+
153
+ def generate_gt_scope(scope_name, field_name)
154
+ @model_class.class_eval <<-RUBY, __FILE__, __LINE__ + 1
155
+ scope :#{scope_name}, ->(value) {
156
+ value.present? ? where("#{field_name} > ?", value) : all
157
+ }
158
+ RUBY
159
+ end
160
+
161
+ def generate_lt_scope(scope_name, field_name)
162
+ @model_class.class_eval <<-RUBY, __FILE__, __LINE__ + 1
163
+ scope :#{scope_name}, ->(value) {
164
+ value.present? ? where("#{field_name} < ?", value) : all
165
+ }
166
+ RUBY
167
+ end
168
+
169
+ def generate_gte_scope(scope_name, field_name)
170
+ @model_class.class_eval <<-RUBY, __FILE__, __LINE__ + 1
171
+ scope :#{scope_name}, ->(value) {
172
+ value.present? ? where("#{field_name} >= ?", value) : all
173
+ }
174
+ RUBY
175
+ end
176
+
177
+ def generate_lte_scope(scope_name, field_name)
178
+ @model_class.class_eval <<-RUBY, __FILE__, __LINE__ + 1
179
+ scope :#{scope_name}, ->(value) {
180
+ value.present? ? where("#{field_name} <= ?", value) : all
181
+ }
182
+ RUBY
183
+ end
184
+
185
+ def generate_before_scope(scope_name, field_name)
186
+ @model_class.class_eval <<-RUBY, __FILE__, __LINE__ + 1
187
+ scope :#{scope_name}, ->(value) {
188
+ value.present? ? where("#{field_name} < ?", value) : all
189
+ }
190
+ RUBY
191
+ end
192
+
193
+ def generate_after_scope(scope_name, field_name)
194
+ @model_class.class_eval <<-RUBY, __FILE__, __LINE__ + 1
195
+ scope :#{scope_name}, ->(value) {
196
+ value.present? ? where("#{field_name} > ?", value) : all
197
+ }
198
+ RUBY
199
+ end
200
+
201
+ def generate_in_scope(scope_name, field_name)
202
+ @model_class.class_eval <<-RUBY, __FILE__, __LINE__ + 1
203
+ scope :#{scope_name}, ->(value) {
204
+ if value.present?
205
+ values = value.is_a?(Array) ? value : value.to_s.split(",").map(&:strip)
206
+ values.any? ? where(#{field_name}: values) : all
207
+ else
208
+ all
209
+ end
210
+ }
211
+ RUBY
212
+ end
213
+
214
+ def generate_null_scope(scope_name, field_name)
215
+ @model_class.class_eval <<-RUBY, __FILE__, __LINE__ + 1
216
+ scope :#{scope_name}, ->(value) {
217
+ if value.present?
218
+ is_null = value.to_s.downcase == "true"
219
+ is_null ? where("#{field_name} IS NULL") : where("#{field_name} IS NOT NULL")
220
+ else
221
+ all
222
+ end
223
+ }
224
+ RUBY
225
+ end
226
+
227
+ def generate_range_scope(scope_name, field_name)
228
+ @model_class.class_eval <<-RUBY, __FILE__, __LINE__ + 1
229
+ scope :#{scope_name}, ->(value) {
230
+ if value.present? && value.is_a?(Array) && value.size == 2
231
+ where("#{field_name} BETWEEN ? AND ?", value[0], value[1])
232
+ else
233
+ all
234
+ end
235
+ }
236
+ RUBY
237
+ end
238
+
239
+ def generate_date_scope(scope_name, field_name)
240
+ @model_class.class_eval <<-RUBY, __FILE__, __LINE__ + 1
241
+ scope :#{scope_name}, ->(value) {
242
+ if value.present?
243
+ date = value.is_a?(Date) ? value : Date.parse(value.to_s)
244
+ where("DATE(#{field_name}) = ?", date)
245
+ else
246
+ all
247
+ end
248
+ }
249
+ RUBY
250
+ end
251
+
252
+ def generate_year_scope(scope_name, field_name)
253
+ @model_class.class_eval <<-RUBY, __FILE__, __LINE__ + 1
254
+ scope :#{scope_name}, ->(value) {
255
+ value.present? ? where("EXTRACT(YEAR FROM #{field_name}) = ?", value) : all
256
+ }
257
+ RUBY
258
+ end
259
+
260
+ def generate_month_scope(scope_name, field_name)
261
+ @model_class.class_eval <<-RUBY, __FILE__, __LINE__ + 1
262
+ scope :#{scope_name}, ->(value) {
263
+ value.present? ? where("EXTRACT(MONTH FROM #{field_name}) = ?", value) : all
264
+ }
265
+ RUBY
266
+ end
267
+
268
+ def generate_day_scope(scope_name, field_name)
269
+ @model_class.class_eval <<-RUBY, __FILE__, __LINE__ + 1
270
+ scope :#{scope_name}, ->(value) {
271
+ value.present? ? where("EXTRACT(DAY FROM #{field_name}) = ?", value) : all
272
+ }
273
+ RUBY
274
+ end
275
+
276
+ def generate_sort_scope
277
+ scope_name = :order_by
278
+ return if @generated_scopes.include?(scope_name)
279
+
280
+ begin
281
+ # Get all sortable columns (exclude sensitive fields)
282
+ sortable_columns = @model_class.columns.reject do |column|
283
+ sensitive_fields.include?(column.name.to_s)
284
+ end.map(&:name)
285
+
286
+ @model_class.class_eval <<-RUBY, __FILE__, __LINE__ + 1
287
+ scope :#{scope_name}, ->(value) {
288
+ return all if value.blank?
289
+
290
+ # Parse sort parameter (e.g., "name", "-name", "name:asc", "name:desc")
291
+ field = value.to_s
292
+ direction = 'asc'
293
+
294
+ # Handle "-field" format for descending
295
+ if field.start_with?('-')
296
+ field = field[1..-1]
297
+ direction = 'desc'
298
+ else
299
+ # Handle "field:direction" format
300
+ field, direction = field.split(':')
301
+ direction ||= 'asc'
302
+ end
303
+
304
+ # Validate field is sortable
305
+ sortable_fields = #{sortable_columns.inspect}
306
+ return all unless sortable_fields.include?(field)
307
+
308
+ # Validate direction
309
+ direction = direction.downcase
310
+ direction = 'asc' unless %w[asc desc].include?(direction)
311
+
312
+ order("\#{field} \#{direction}")
313
+ }
314
+ RUBY
315
+
316
+ @generated_scopes << scope_name
317
+ rescue => e
318
+ Rails.logger.error "Error generating sort scope: #{e.message}"
319
+ Rails.logger.error e.backtrace.first(5)
320
+ end
321
+ end
322
+
323
+ def register_has_scope(controller_class, scope_name)
324
+ return if controller_class.respond_to?(:has_scope_registered?) &&
325
+ controller_class.has_scope_registered?(scope_name)
326
+
327
+ controller_class.class_eval <<-RUBY, __FILE__, __LINE__ + 1
328
+ has_scope :#{scope_name}
329
+ RUBY
330
+
331
+ @generated_has_scopes << scope_name
332
+ rescue StandardError => e
333
+ Rails.logger.warn "Error registering has_scope #{scope_name}: #{e.message}"
334
+ end
335
+
336
+ # Returns the appropriate LIKE operator for the current database adapter
337
+ def like_operator
338
+ case @model_class.connection.adapter_name.downcase
339
+ when 'postgresql', 'postgres'
340
+ 'ILIKE'
341
+ when 'mysql', 'mysql2'
342
+ 'LIKE'
343
+ when 'sqlite', 'sqlite3'
344
+ 'LIKE'
345
+ else
346
+ 'LIKE' # Default to LIKE for unknown adapters
347
+ end
348
+ end
349
+
350
+ # Returns a list of sensitive field names that should not be sortable
351
+ def sensitive_fields
352
+ %w[
353
+ password password_digest encrypted_password
354
+ token secret_key api_key
355
+ encrypted_data encrypted_attributes
356
+ salt iv
357
+ created_at updated_at
358
+ ]
359
+ end
360
+ end