jpie 1.0.0 → 1.0.2

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 (76) hide show
  1. checksums.yaml +4 -4
  2. data/.cursor/rules/release.mdc +62 -0
  3. data/.gitignore +5 -0
  4. data/.rubocop.yml +82 -38
  5. data/Gemfile +13 -10
  6. data/Gemfile.lock +18 -1
  7. data/README.md +675 -1235
  8. data/Rakefile +22 -0
  9. data/jpie.gemspec +15 -15
  10. data/kiln/app/resources/user_message_resource.rb +2 -0
  11. data/lib/jpie.rb +0 -1
  12. data/lib/json_api/active_storage/deserialization.rb +32 -22
  13. data/lib/json_api/active_storage/detection.rb +36 -41
  14. data/lib/json_api/active_storage/serialization.rb +13 -11
  15. data/lib/json_api/configuration.rb +4 -5
  16. data/lib/json_api/controllers/base_controller.rb +3 -3
  17. data/lib/json_api/controllers/concerns/controller_helpers/authorization.rb +30 -0
  18. data/lib/json_api/controllers/concerns/controller_helpers/document_meta.rb +20 -0
  19. data/lib/json_api/controllers/concerns/controller_helpers/error_rendering.rb +64 -0
  20. data/lib/json_api/controllers/concerns/controller_helpers/parsing.rb +127 -0
  21. data/lib/json_api/controllers/concerns/controller_helpers/resource_setup.rb +38 -0
  22. data/lib/json_api/controllers/concerns/controller_helpers.rb +11 -215
  23. data/lib/json_api/controllers/concerns/relationships/active_storage_removal.rb +65 -0
  24. data/lib/json_api/controllers/concerns/relationships/events.rb +44 -0
  25. data/lib/json_api/controllers/concerns/relationships/removal.rb +92 -0
  26. data/lib/json_api/controllers/concerns/relationships/response_helpers.rb +55 -0
  27. data/lib/json_api/controllers/concerns/relationships/serialization.rb +72 -0
  28. data/lib/json_api/controllers/concerns/relationships/sorting.rb +114 -0
  29. data/lib/json_api/controllers/concerns/relationships/updating.rb +73 -0
  30. data/lib/json_api/controllers/concerns/resource_actions/crud_helpers.rb +93 -0
  31. data/lib/json_api/controllers/concerns/resource_actions/field_validation.rb +114 -0
  32. data/lib/json_api/controllers/concerns/resource_actions/filter_validation.rb +91 -0
  33. data/lib/json_api/controllers/concerns/resource_actions/pagination.rb +51 -0
  34. data/lib/json_api/controllers/concerns/resource_actions/preloading.rb +64 -0
  35. data/lib/json_api/controllers/concerns/resource_actions/resource_loading.rb +71 -0
  36. data/lib/json_api/controllers/concerns/resource_actions/serialization.rb +63 -0
  37. data/lib/json_api/controllers/concerns/resource_actions/type_validation.rb +75 -0
  38. data/lib/json_api/controllers/concerns/resource_actions.rb +51 -602
  39. data/lib/json_api/controllers/relationships_controller.rb +26 -422
  40. data/lib/json_api/errors/parameter_not_allowed.rb +1 -1
  41. data/lib/json_api/railtie.rb +46 -9
  42. data/lib/json_api/resources/concerns/attributes_dsl.rb +69 -0
  43. data/lib/json_api/resources/concerns/filters_dsl.rb +32 -0
  44. data/lib/json_api/resources/concerns/meta_dsl.rb +23 -0
  45. data/lib/json_api/resources/concerns/model_class_helpers.rb +37 -0
  46. data/lib/json_api/resources/concerns/relationships_dsl.rb +71 -0
  47. data/lib/json_api/resources/concerns/sortable_fields_dsl.rb +36 -0
  48. data/lib/json_api/resources/resource.rb +13 -219
  49. data/lib/json_api/routing.rb +56 -47
  50. data/lib/json_api/serialization/concerns/attributes_deserialization.rb +27 -0
  51. data/lib/json_api/serialization/concerns/attributes_serialization.rb +50 -0
  52. data/lib/json_api/serialization/concerns/deserialization_helpers.rb +115 -0
  53. data/lib/json_api/serialization/concerns/includes_serialization.rb +82 -0
  54. data/lib/json_api/serialization/concerns/links_serialization.rb +33 -0
  55. data/lib/json_api/serialization/concerns/meta_serialization.rb +60 -0
  56. data/lib/json_api/serialization/concerns/model_attributes_transformation.rb +69 -0
  57. data/lib/json_api/serialization/concerns/relationship_processing.rb +119 -0
  58. data/lib/json_api/serialization/concerns/relationships_deserialization.rb +47 -0
  59. data/lib/json_api/serialization/concerns/relationships_serialization.rb +81 -0
  60. data/lib/json_api/serialization/deserializer.rb +10 -346
  61. data/lib/json_api/serialization/serializer.rb +17 -260
  62. data/lib/json_api/support/active_storage_support.rb +10 -13
  63. data/lib/json_api/support/collection_query.rb +14 -370
  64. data/lib/json_api/support/concerns/condition_building.rb +57 -0
  65. data/lib/json_api/support/concerns/nested_filters.rb +130 -0
  66. data/lib/json_api/support/concerns/pagination.rb +30 -0
  67. data/lib/json_api/support/concerns/polymorphic_filters.rb +75 -0
  68. data/lib/json_api/support/concerns/regular_filters.rb +81 -0
  69. data/lib/json_api/support/concerns/sorting.rb +88 -0
  70. data/lib/json_api/support/instrumentation.rb +13 -12
  71. data/lib/json_api/support/param_helpers.rb +9 -6
  72. data/lib/json_api/support/relationship_helpers.rb +4 -2
  73. data/lib/json_api/support/resource_identifier.rb +29 -29
  74. data/lib/json_api/support/responders.rb +5 -5
  75. data/lib/json_api/version.rb +1 -1
  76. metadata +44 -1
@@ -17,19 +17,16 @@ module JSONAPI
17
17
  end
18
18
 
19
19
  def active_storage_attachment?(association_name, model_class = nil)
20
- return self.class.active_storage_attachment?(association_name, model_class) if model_class
21
-
22
- resolved_model_class = if respond_to?(:model_class, true)
23
- send(:model_class)
24
- elsif respond_to?(:resource_model_class, true)
25
- send(:resource_model_class)
26
- elsif respond_to?(:resource, true) && (res = send(:resource)) && res.respond_to?(:class)
27
- res.class
28
- else
29
- raise NotImplementedError,
30
- "Must implement resource_model_class or provide model_class parameter"
31
- end
32
- self.class.active_storage_attachment?(association_name, resolved_model_class)
20
+ resolved = model_class || resolve_model_class_for_attachment
21
+ self.class.active_storage_attachment?(association_name, resolved)
22
+ end
23
+
24
+ def resolve_model_class_for_attachment
25
+ return send(:model_class) if respond_to?(:model_class, true)
26
+ return send(:resource_model_class) if respond_to?(:resource_model_class, true)
27
+ return send(:resource).class if respond_to?(:resource, true) && send(:resource).respond_to?(:class)
28
+
29
+ raise NotImplementedError, "Must implement resource_model_class or provide model_class parameter"
33
30
  end
34
31
 
35
32
  def extract_active_storage_params_from_hash(params_hash, model_class)
@@ -1,7 +1,21 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "concerns/sorting"
4
+ require_relative "concerns/regular_filters"
5
+ require_relative "concerns/nested_filters"
6
+ require_relative "concerns/polymorphic_filters"
7
+ require_relative "concerns/condition_building"
8
+ require_relative "concerns/pagination"
9
+
3
10
  module JSONAPI
4
11
  class CollectionQuery
12
+ include Support::Sorting
13
+ include Support::RegularFilters
14
+ include Support::NestedFilters
15
+ include Support::PolymorphicFilters
16
+ include Support::ConditionBuilding
17
+ include Support::Pagination
18
+
5
19
  attr_reader :scope, :total_count, :pagination_applied
6
20
 
7
21
  def initialize(scope, definition:, model_class:, filter_params:, sort_params:, page_params:)
@@ -15,23 +29,12 @@ module JSONAPI
15
29
  end
16
30
 
17
31
  def execute
18
- # Apply filtering
19
32
  @scope = apply_filtering
20
-
21
- # Get total count before sorting (for virtual attributes, we need to load records)
22
- # If we have virtual attribute sorting, we'll recalculate after sorting
23
33
  has_virtual_sort = sort_params.any? { |sort_field| virtual_attribute_sort?(sort_field) }
24
34
  @total_count = @scope.count unless has_virtual_sort
25
-
26
- # Apply sorting (may convert scope to array if virtual attributes are involved)
27
35
  @scope = apply_sorting(@scope)
28
-
29
- # Recalculate total count if we converted to array for virtual sorting
30
36
  @total_count = @scope.count if has_virtual_sort && @scope.is_a?(Array)
31
-
32
- # Apply pagination
33
37
  @scope = apply_pagination
34
-
35
38
  self
36
39
  end
37
40
 
@@ -40,367 +43,8 @@ module JSONAPI
40
43
  attr_reader :definition, :model_class, :filter_params, :sort_params, :page_params
41
44
 
42
45
  def apply_filtering
43
- # Apply nested relationship filters first (they require joins)
44
46
  scope = apply_nested_relationship_filters(@scope)
45
-
46
- # Then apply regular filters (column-aware operators or model scopes)
47
47
  apply_regular_filters(scope)
48
48
  end
49
-
50
- def apply_sorting(scope)
51
- return scope if sort_params.empty?
52
-
53
- # Check if any sort fields are virtual attributes
54
- has_virtual_sorts = sort_params.any? { |sort_field| virtual_attribute_sort?(sort_field) }
55
-
56
- if has_virtual_sorts
57
- # Convert to array and sort by all fields (both DB and virtual) in Ruby
58
- records = scope.to_a
59
- records = apply_mixed_sorting(records, sort_params)
60
- scope = records
61
- else
62
- # All sorts are database columns - use ActiveRecord ordering
63
- sort_params.each do |sort_field|
64
- direction = RelationshipHelpers.extract_sort_direction(sort_field)
65
- field = RelationshipHelpers.extract_sort_field_name(sort_field)
66
- scope = scope.order(field => direction)
67
- end
68
- end
69
-
70
- scope
71
- end
72
-
73
- def virtual_attribute_sort?(sort_field)
74
- field = RelationshipHelpers.extract_sort_field_name(sort_field)
75
- !model_class.column_names.include?(field.to_s)
76
- end
77
-
78
- def apply_mixed_sorting(records, all_sorts)
79
- records.sort do |a, b|
80
- compare_by_sort_criteria(a, b, all_sorts)
81
- end
82
- end
83
-
84
- def compare_by_sort_criteria(record_a, record_b, all_sorts)
85
- all_sorts.each do |sort_field|
86
- direction = RelationshipHelpers.extract_sort_direction(sort_field)
87
- field = RelationshipHelpers.extract_sort_field_name(sort_field)
88
-
89
- # Get values - use DB column if available, otherwise use definition getter
90
- value_a = fetch_sort_value(record_a, field)
91
- value_b = fetch_sort_value(record_b, field)
92
-
93
- # Compare values
94
- comparison = compare_values(value_a, value_b)
95
- next if comparison.zero? # Equal, check next sort field
96
-
97
- # Apply direction
98
- return direction == :desc ? -comparison : comparison
99
- end
100
-
101
- 0 # All fields equal
102
- end
103
-
104
- def fetch_sort_value(record, field)
105
- # Try database column first
106
- if model_class.column_names.include?(field.to_s)
107
- # Use public_send to get the attribute value (handles both string and symbol access)
108
- if record.respond_to?(field.to_sym)
109
- record.public_send(field.to_sym)
110
- elsif record.respond_to?(:attributes) && record.attributes.is_a?(Hash)
111
- record.attributes[field.to_s] || record.attributes[field.to_sym]
112
- end
113
- else
114
- # Use definition getter for virtual attributes
115
- definition_instance = definition.new(record, {})
116
- fetch_virtual_value(definition_instance, field)
117
- end
118
- end
119
-
120
- def fetch_virtual_value(definition_instance, field)
121
- field_sym = field.to_sym
122
- return definition_instance.public_send(field_sym) if definition_instance.respond_to?(field_sym, false)
123
-
124
- nil
125
- end
126
-
127
- def compare_values(value_a, value_b)
128
- # Handle nil values
129
- return 0 if value_a.nil? && value_b.nil?
130
- return -1 if value_a.nil?
131
- return 1 if value_b.nil?
132
-
133
- # Compare using standard Ruby comparison
134
- value_a <=> value_b
135
- end
136
-
137
- def apply_regular_filters(scope)
138
- return scope if filter_params.empty?
139
-
140
- # Exclude nested relationship filters (already applied)
141
- regular_filters = filter_params.reject { |k, _v| k.to_s.include?(".") }
142
- return scope if regular_filters.empty?
143
-
144
- regular_filters.reduce(scope) do |current_scope, (filter_name, filter_value)|
145
- apply_regular_filter(current_scope, filter_name.to_s, filter_value)
146
- end
147
- end
148
-
149
- def apply_regular_filter(scope, filter_name, filter_value)
150
- return scope if filter_value.respond_to?(:empty?) ? filter_value.empty? : filter_value.nil?
151
-
152
- column_filter = parse_column_filter(filter_name)
153
- if column_filter
154
- apply_column_filter(scope, column_filter, filter_value)
155
- else
156
- apply_scope_fallback(scope, filter_name, filter_value)
157
- end
158
- end
159
-
160
- def parse_column_filter(filter_name)
161
- match = filter_name.match(/\A(.+)_(eq|match|lt|lte|gt|gte)\z/)
162
- return nil unless match
163
-
164
- column_name = match[1]
165
- operator = match[2].to_sym
166
-
167
- { column: column_name, operator: }
168
- end
169
-
170
- def apply_column_filter(scope, column_filter, raw_value)
171
- column_name = column_filter[:column]
172
- operator = column_filter[:operator]
173
-
174
- column = model_class.column_for_attribute(column_name)
175
- return scope unless column
176
-
177
- value = normalize_filter_value(column, raw_value)
178
- return scope if value.nil?
179
-
180
- condition = build_condition(column, value, operator)
181
- return scope unless condition
182
-
183
- apply_condition(scope, condition)
184
- rescue ArgumentError, TypeError, StandardError => e
185
- # If casting fails or any other error occurs, skip filter
186
- Rails.logger.warn("Filter error for #{column_filter[:column]}_#{operator}: #{e.class} - #{e.message}") if defined?(Rails.logger)
187
- scope
188
- end
189
-
190
- def normalize_filter_value(column, raw_value)
191
- value = raw_value.is_a?(Array) ? raw_value.first : raw_value
192
- return nil if value.nil?
193
-
194
- type = model_class.type_for_attribute(column.name)
195
- type.cast(value)
196
- end
197
-
198
- def build_condition(column, value, operator)
199
- attr = model_class.arel_table[column.name]
200
-
201
- case operator
202
- when :eq
203
- attr.eq(value)
204
- when :lt
205
- attr.lt(value)
206
- when :lte
207
- attr.lteq(value)
208
- when :gt
209
- attr.gt(value)
210
- when :gte
211
- attr.gteq(value)
212
- when :match
213
- pattern = "%#{ActiveRecord::Base.sanitize_sql_like(value.to_s)}%"
214
- lower_attr = Arel::Nodes::NamedFunction.new("LOWER", [attr])
215
- lower_attr.matches(pattern.downcase)
216
- end
217
- end
218
-
219
- def normalize_filter_value_for_model(model, column, raw_value)
220
- return nil unless column
221
-
222
- value = raw_value.is_a?(Array) ? raw_value.first : raw_value
223
- return nil if value.nil?
224
-
225
- type = model.type_for_attribute(column.name)
226
- type.cast(value)
227
- end
228
-
229
- def build_condition_for_model(model, column, value, operator)
230
- attr = model.arel_table[column.name]
231
-
232
- case operator
233
- when :eq
234
- attr.eq(value)
235
- when :lt
236
- attr.lt(value)
237
- when :lte
238
- attr.lteq(value)
239
- when :gt
240
- attr.gt(value)
241
- when :gte
242
- attr.gteq(value)
243
- when :match
244
- pattern = "%#{ActiveRecord::Base.sanitize_sql_like(value.to_s)}%"
245
- lower_attr = Arel::Nodes::NamedFunction.new("LOWER", [attr])
246
- lower_attr.matches(pattern.downcase)
247
- end
248
- end
249
-
250
- def apply_condition(scope, condition)
251
- scope.where(condition)
252
- end
253
-
254
- def apply_scope_fallback(scope, filter_name, filter_value)
255
- return scope unless model_class.respond_to?(filter_name.to_sym)
256
-
257
- scope.public_send(filter_name.to_sym, filter_value)
258
- rescue ArgumentError, NoMethodError
259
- scope
260
- end
261
-
262
- def apply_nested_relationship_filters(scope)
263
- return scope if filter_params.empty?
264
-
265
- nested_filters = filter_params.select { |k, _v| k.to_s.include?(".") }
266
- return scope if nested_filters.empty?
267
-
268
- nested_filters.reduce(scope) do |current_scope, (filter_name, filter_value)|
269
- apply_filter_for_path(current_scope, filter_name.to_s, filter_value)
270
- end
271
- end
272
-
273
- def apply_filter_for_path(scope, filter_name, filter_value)
274
- parts = filter_name.split(".")
275
- return scope if parts.length < 2
276
-
277
- relationship_chain = parts[0..-2]
278
- leaf_filter = parts.last
279
-
280
- current_model = model_class
281
- current_definition = definition
282
-
283
- relationship_chain.each do |relationship_name|
284
- association = current_model.reflect_on_association(relationship_name.to_sym)
285
- return scope unless association
286
-
287
- if association.polymorphic?
288
- attributes = { leaf_filter => filter_value }
289
- return apply_polymorphic_nested_filters(scope, association, relationship_name, attributes)
290
- end
291
-
292
- current_model = association.klass
293
- current_definition = JSONAPI::Resource.resource_for_model(current_model)
294
- return scope unless current_definition
295
- end
296
-
297
- join_hash = build_join_hash_for_chain(relationship_chain)
298
- scope = scope.joins(join_hash) if join_hash.present?
299
-
300
- apply_filter_on_model(scope, current_model, current_definition, leaf_filter, filter_value)
301
- end
302
-
303
- def build_join_hash_for_chain(chain)
304
- return nil if chain.empty?
305
-
306
- chain.reverse.reduce(nil) do |acc, name|
307
- if acc.nil?
308
- name.to_sym
309
- else
310
- { name.to_sym => acc }
311
- end
312
- end
313
- end
314
-
315
- def apply_filter_on_model(scope, target_model, target_resource, filter_name, filter_value)
316
- return scope if filter_value.respond_to?(:empty?) ? filter_value.empty? : filter_value.nil?
317
-
318
- column_filter = parse_column_filter(filter_name)
319
- if column_filter
320
- column = target_model.column_for_attribute(column_filter[:column])
321
- if column
322
- value = normalize_filter_value_for_model(target_model, column, filter_value)
323
- condition = build_condition_for_model(target_model, column, value, column_filter[:operator]) if value
324
- return apply_condition(scope, condition) if condition
325
- end
326
- end
327
-
328
- if target_model.column_names.include?(filter_name)
329
- table_name = target_model.table_name
330
- return scope.where(table_name => { filter_name => filter_value })
331
- end
332
-
333
- if target_model.respond_to?(filter_name.to_sym)
334
- begin
335
- return scope.merge(target_model.public_send(filter_name.to_sym, filter_value))
336
- rescue ArgumentError, NoMethodError
337
- # Fall through to return scope
338
- end
339
- end
340
-
341
- if target_resource &&
342
- target_resource.permitted_filters.map(&:to_s).include?(filter_name) &&
343
- target_model.respond_to?(filter_name.to_sym)
344
- begin
345
- return scope.merge(target_model.public_send(filter_name.to_sym, filter_value))
346
- rescue ArgumentError, NoMethodError
347
- # Fall through to return scope
348
- end
349
- end
350
-
351
- scope
352
- end
353
-
354
- def apply_polymorphic_nested_filters(scope, association, _relationship_name, attributes)
355
- foreign_key = association.foreign_key
356
- foreign_type = association.foreign_type
357
- fk_column = model_class.column_for_attribute(foreign_key)
358
- type_value = attributes["type"] || attributes["type_eq"]
359
-
360
- attributes.each do |attr_name, attr_value|
361
- next if attr_value.respond_to?(:empty?) ? attr_value.empty? : attr_value.nil?
362
- next if attr_name == "type"
363
-
364
- column_filter = parse_column_filter(attr_name)
365
- if column_filter && column_filter[:column] == "id" && fk_column
366
- value = normalize_filter_value_for_model(model_class, fk_column, attr_value)
367
- condition = build_condition_for_model(model_class, fk_column, value, column_filter[:operator]) if value
368
- scope = apply_condition(scope, condition) if condition
369
- elsif attr_name == "id"
370
- scope = scope.where(foreign_key => attr_value)
371
- end
372
-
373
- if foreign_type && association.options[:class_name]
374
- scope = scope.where(foreign_type => association.options[:class_name])
375
- elsif foreign_type && type_value
376
- scope = scope.where(foreign_type => type_value)
377
- end
378
- end
379
-
380
- if foreign_type && association.options[:class_name].nil? && type_value
381
- scope = scope.where(foreign_type => type_value)
382
- end
383
-
384
- scope
385
- end
386
-
387
- def apply_pagination
388
- return @scope if page_params.empty?
389
-
390
- number = page_params["number"]&.to_i || 1
391
- size = page_params["size"]&.to_i || JSONAPI.configuration.default_page_size
392
-
393
- # Enforce max page size
394
- size = [size, JSONAPI.configuration.max_page_size].min
395
-
396
- offset = (number - 1) * size
397
-
398
- # Handle array (after virtual attribute sorting) vs ActiveRecord relation
399
- if @scope.is_a?(Array)
400
- @scope.slice(offset, size) || []
401
- else
402
- @scope.offset(offset).limit(size)
403
- end
404
- end
405
49
  end
406
50
  end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JSONAPI
4
+ module Support
5
+ module ConditionBuilding
6
+ private
7
+
8
+ def build_condition(column, value, operator)
9
+ build_arel_condition(model_class, column, value, operator)
10
+ end
11
+
12
+ def normalize_filter_value_for_model(model, column, raw_value)
13
+ return nil unless column
14
+
15
+ value = raw_value.is_a?(Array) ? raw_value.first : raw_value
16
+ return nil if value.nil?
17
+
18
+ type = model.type_for_attribute(column.name)
19
+ type.cast(value)
20
+ end
21
+
22
+ def build_condition_for_model(model, column, value, operator)
23
+ build_arel_condition(model, column, value, operator)
24
+ end
25
+
26
+ def build_arel_condition(model, column, value, operator)
27
+ attr = model.arel_table[column.name]
28
+ build_operator_condition(attr, value, operator)
29
+ end
30
+
31
+ def build_operator_condition(attr, value, operator)
32
+ case operator
33
+ when :eq then attr.eq(value)
34
+ when :lt then attr.lt(value)
35
+ when :lte then attr.lteq(value)
36
+ when :gt then attr.gt(value)
37
+ when :gte then attr.gteq(value)
38
+ when :match then build_match_condition(attr, value)
39
+ end
40
+ end
41
+
42
+ def build_match_condition(attr, value)
43
+ pattern = "%#{ActiveRecord::Base.sanitize_sql_like(value.to_s)}%"
44
+ lower_attr = Arel::Nodes::NamedFunction.new("LOWER", [attr])
45
+ lower_attr.matches(pattern.downcase)
46
+ end
47
+
48
+ def apply_condition(scope, condition)
49
+ scope.where(condition)
50
+ end
51
+
52
+ def empty_filter_value?(filter_value)
53
+ filter_value.respond_to?(:empty?) ? filter_value.empty? : filter_value.nil?
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JSONAPI
4
+ module Support
5
+ module NestedFilters
6
+ def apply_nested_relationship_filters(scope)
7
+ return scope if filter_params.empty?
8
+
9
+ nested_filters = filter_params.select { |k, _v| k.to_s.include?(".") }
10
+ return scope if nested_filters.empty?
11
+
12
+ nested_filters.reduce(scope) do |current_scope, (filter_name, filter_value)|
13
+ apply_filter_for_path(current_scope, filter_name.to_s, filter_value)
14
+ end
15
+ end
16
+
17
+ private
18
+
19
+ def apply_filter_for_path(scope, filter_name, filter_value)
20
+ parts = filter_name.split(".")
21
+ return scope if parts.length < 2
22
+
23
+ relationship_chain = parts[0..-2]
24
+ leaf_filter = parts.last
25
+
26
+ result = traverse_relationship_chain(scope, relationship_chain, leaf_filter, filter_value)
27
+ return result[:scope] if result[:early_return]
28
+
29
+ apply_joined_filter(scope, result, relationship_chain:, leaf_filter:, filter_value:)
30
+ end
31
+
32
+ def traverse_relationship_chain(scope, relationship_chain, leaf_filter, filter_value)
33
+ current_model = model_class
34
+ current_definition = definition
35
+
36
+ relationship_chain.each do |relationship_name|
37
+ result = process_chain_step(scope, current_model, relationship_name, leaf_filter, filter_value)
38
+ return result if result[:early_return]
39
+
40
+ current_model = result[:model]
41
+ current_definition = result[:definition]
42
+ end
43
+
44
+ { model: current_model, definition: current_definition, early_return: false }
45
+ end
46
+
47
+ def process_chain_step(scope, current_model, relationship_name, leaf_filter, filter_value)
48
+ association = current_model.reflect_on_association(relationship_name.to_sym)
49
+ return { scope:, early_return: true } unless association
50
+
51
+ if association.polymorphic?
52
+ attributes = { leaf_filter => filter_value }
53
+ return { scope: apply_polymorphic_nested_filters(scope, association, relationship_name, attributes),
54
+ early_return: true, }
55
+ end
56
+
57
+ next_definition = JSONAPI::Resource.resource_for_model(association.klass)
58
+ return { scope:, early_return: true } unless next_definition
59
+
60
+ { model: association.klass, definition: next_definition, early_return: false }
61
+ end
62
+
63
+ def apply_joined_filter(scope, result, relationship_chain:, leaf_filter:, filter_value:)
64
+ join_hash = build_join_hash_for_chain(relationship_chain)
65
+ scope = scope.joins(join_hash) if join_hash.present?
66
+ apply_filter_on_model(scope, result[:model], result[:definition], leaf_filter, filter_value)
67
+ end
68
+
69
+ def build_join_hash_for_chain(chain)
70
+ return nil if chain.empty?
71
+
72
+ chain.reverse.reduce(nil) do |acc, name|
73
+ if acc.nil?
74
+ name.to_sym
75
+ else
76
+ { name.to_sym => acc }
77
+ end
78
+ end
79
+ end
80
+
81
+ def apply_filter_on_model(scope, target_model, target_resource, filter_name, filter_value)
82
+ return scope if empty_filter_value?(filter_value)
83
+
84
+ apply_column_operator_filter(scope, target_model, filter_name, filter_value) ||
85
+ apply_direct_column_filter(scope, target_model, filter_name, filter_value) ||
86
+ apply_scope_method_filter(scope, target_model, target_resource, filter_name, filter_value) ||
87
+ scope
88
+ end
89
+
90
+ def apply_column_operator_filter(scope, target_model, filter_name, filter_value)
91
+ column_filter = parse_column_filter(filter_name)
92
+ return nil unless column_filter
93
+
94
+ column = target_model.column_for_attribute(column_filter[:column])
95
+ return nil unless column
96
+
97
+ value = normalize_filter_value_for_model(target_model, column, filter_value)
98
+ return nil unless value
99
+
100
+ condition = build_condition_for_model(target_model, column, value, column_filter[:operator])
101
+ condition ? apply_condition(scope, condition) : nil
102
+ end
103
+
104
+ def apply_direct_column_filter(scope, target_model, filter_name, filter_value)
105
+ return nil unless target_model.column_names.include?(filter_name)
106
+
107
+ scope.where(target_model.table_name => { filter_name => filter_value })
108
+ end
109
+
110
+ def apply_scope_method_filter(scope, target_model, target_resource, filter_name, filter_value)
111
+ if target_model.respond_to?(filter_name.to_sym)
112
+ return try_scope_method(scope, target_model, filter_name,
113
+ filter_value,)
114
+ end
115
+
116
+ return nil unless target_resource
117
+ return nil unless target_resource.permitted_filters.map(&:to_s).include?(filter_name)
118
+ return nil unless target_model.respond_to?(filter_name.to_sym)
119
+
120
+ try_scope_method(scope, target_model, filter_name, filter_value)
121
+ end
122
+
123
+ def try_scope_method(scope, target_model, filter_name, filter_value)
124
+ scope.merge(target_model.public_send(filter_name.to_sym, filter_value))
125
+ rescue ArgumentError, NoMethodError
126
+ nil
127
+ end
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JSONAPI
4
+ module Support
5
+ module Pagination
6
+ def apply_pagination
7
+ return @scope if page_params.empty?
8
+
9
+ offset, size = calculate_pagination_params
10
+ paginate_scope(offset, size)
11
+ end
12
+
13
+ private
14
+
15
+ def calculate_pagination_params
16
+ number = page_params["number"]&.to_i || 1
17
+ size = page_params["size"]&.to_i || JSONAPI.configuration.default_page_size
18
+ size = [size, JSONAPI.configuration.max_page_size].min
19
+ offset = (number - 1) * size
20
+ [offset, size]
21
+ end
22
+
23
+ def paginate_scope(offset, size)
24
+ return @scope.slice(offset, size) || [] if @scope.is_a?(Array)
25
+
26
+ @scope.offset(offset).limit(size)
27
+ end
28
+ end
29
+ end
30
+ end