jpie 1.0.0 → 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.
- checksums.yaml +4 -4
- data/.cursor/rules/release.mdc +62 -0
- data/.gitignore +5 -0
- data/.rubocop.yml +82 -38
- data/Gemfile +12 -10
- data/Gemfile.lock +10 -1
- data/README.md +675 -1235
- data/jpie.gemspec +15 -15
- data/kiln/app/resources/user_message_resource.rb +2 -0
- data/lib/jpie.rb +0 -1
- data/lib/json_api/active_storage/deserialization.rb +32 -22
- data/lib/json_api/active_storage/detection.rb +36 -41
- data/lib/json_api/active_storage/serialization.rb +13 -11
- data/lib/json_api/configuration.rb +4 -5
- data/lib/json_api/controllers/base_controller.rb +3 -3
- data/lib/json_api/controllers/concerns/controller_helpers/authorization.rb +30 -0
- data/lib/json_api/controllers/concerns/controller_helpers/document_meta.rb +20 -0
- data/lib/json_api/controllers/concerns/controller_helpers/error_rendering.rb +64 -0
- data/lib/json_api/controllers/concerns/controller_helpers/parsing.rb +127 -0
- data/lib/json_api/controllers/concerns/controller_helpers/resource_setup.rb +38 -0
- data/lib/json_api/controllers/concerns/controller_helpers.rb +11 -215
- data/lib/json_api/controllers/concerns/relationships/active_storage_removal.rb +65 -0
- data/lib/json_api/controllers/concerns/relationships/events.rb +44 -0
- data/lib/json_api/controllers/concerns/relationships/removal.rb +92 -0
- data/lib/json_api/controllers/concerns/relationships/response_helpers.rb +55 -0
- data/lib/json_api/controllers/concerns/relationships/serialization.rb +72 -0
- data/lib/json_api/controllers/concerns/relationships/sorting.rb +114 -0
- data/lib/json_api/controllers/concerns/relationships/updating.rb +73 -0
- data/lib/json_api/controllers/concerns/relationships_controller/active_storage_removal.rb +67 -0
- data/lib/json_api/controllers/concerns/relationships_controller/events.rb +44 -0
- data/lib/json_api/controllers/concerns/relationships_controller/removal.rb +92 -0
- data/lib/json_api/controllers/concerns/relationships_controller/response_helpers.rb +55 -0
- data/lib/json_api/controllers/concerns/relationships_controller/serialization.rb +72 -0
- data/lib/json_api/controllers/concerns/relationships_controller/sorting.rb +114 -0
- data/lib/json_api/controllers/concerns/relationships_controller/updating.rb +73 -0
- data/lib/json_api/controllers/concerns/resource_actions/crud_helpers.rb +93 -0
- data/lib/json_api/controllers/concerns/resource_actions/field_validation.rb +114 -0
- data/lib/json_api/controllers/concerns/resource_actions/filter_validation.rb +91 -0
- data/lib/json_api/controllers/concerns/resource_actions/pagination.rb +51 -0
- data/lib/json_api/controllers/concerns/resource_actions/preloading.rb +64 -0
- data/lib/json_api/controllers/concerns/resource_actions/resource_loading.rb +71 -0
- data/lib/json_api/controllers/concerns/resource_actions/serialization.rb +63 -0
- data/lib/json_api/controllers/concerns/resource_actions/type_validation.rb +75 -0
- data/lib/json_api/controllers/concerns/resource_actions.rb +51 -602
- data/lib/json_api/controllers/relationships_controller.rb +26 -422
- data/lib/json_api/errors/parameter_not_allowed.rb +1 -1
- data/lib/json_api/railtie.rb +46 -9
- data/lib/json_api/resources/active_storage_blob_resource.rb +9 -1
- data/lib/json_api/resources/concerns/attributes_dsl.rb +69 -0
- data/lib/json_api/resources/concerns/filters_dsl.rb +32 -0
- data/lib/json_api/resources/concerns/meta_dsl.rb +23 -0
- data/lib/json_api/resources/concerns/model_class_helpers.rb +37 -0
- data/lib/json_api/resources/concerns/relationships_dsl.rb +71 -0
- data/lib/json_api/resources/concerns/sortable_fields_dsl.rb +36 -0
- data/lib/json_api/resources/resource.rb +13 -219
- data/lib/json_api/routing.rb +56 -47
- data/lib/json_api/serialization/concerns/attributes_deserialization.rb +27 -0
- data/lib/json_api/serialization/concerns/attributes_serialization.rb +50 -0
- data/lib/json_api/serialization/concerns/deserialization_helpers.rb +115 -0
- data/lib/json_api/serialization/concerns/includes_serialization.rb +82 -0
- data/lib/json_api/serialization/concerns/links_serialization.rb +33 -0
- data/lib/json_api/serialization/concerns/meta_serialization.rb +60 -0
- data/lib/json_api/serialization/concerns/model_attributes_transformation.rb +69 -0
- data/lib/json_api/serialization/concerns/relationship_processing.rb +119 -0
- data/lib/json_api/serialization/concerns/relationships_deserialization.rb +47 -0
- data/lib/json_api/serialization/concerns/relationships_serialization.rb +81 -0
- data/lib/json_api/serialization/deserializer.rb +10 -346
- data/lib/json_api/serialization/serializer.rb +17 -260
- data/lib/json_api/support/active_storage_support.rb +10 -13
- data/lib/json_api/support/collection_query.rb +14 -370
- data/lib/json_api/support/concerns/condition_building.rb +57 -0
- data/lib/json_api/support/concerns/nested_filters.rb +130 -0
- data/lib/json_api/support/concerns/pagination.rb +30 -0
- data/lib/json_api/support/concerns/polymorphic_filters.rb +75 -0
- data/lib/json_api/support/concerns/regular_filters.rb +81 -0
- data/lib/json_api/support/concerns/sorting.rb +88 -0
- data/lib/json_api/support/instrumentation.rb +13 -12
- data/lib/json_api/support/param_helpers.rb +9 -6
- data/lib/json_api/support/relationship_helpers.rb +4 -2
- data/lib/json_api/support/resource_identifier.rb +29 -29
- data/lib/json_api/support/responders.rb +5 -5
- data/lib/json_api/version.rb +1 -1
- metadata +51 -1
|
@@ -17,19 +17,16 @@ module JSONAPI
|
|
|
17
17
|
end
|
|
18
18
|
|
|
19
19
|
def active_storage_attachment?(association_name, model_class = nil)
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|