jpie 0.1.0 → 0.3.0

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 (67) hide show
  1. checksums.yaml +4 -4
  2. data/.aiconfig +65 -0
  3. data/.rubocop.yml +110 -35
  4. data/CHANGELOG.md +93 -0
  5. data/LICENSE.txt +21 -0
  6. data/README.md +776 -1903
  7. data/Rakefile +14 -3
  8. data/jpie.gemspec +35 -18
  9. data/lib/jpie/configuration.rb +12 -0
  10. data/lib/jpie/controller/crud_actions.rb +110 -0
  11. data/lib/jpie/controller/error_handling.rb +41 -0
  12. data/lib/jpie/controller/parameter_parsing.rb +35 -0
  13. data/lib/jpie/controller/rendering.rb +60 -0
  14. data/lib/jpie/controller.rb +18 -0
  15. data/lib/jpie/deserializer.rb +110 -0
  16. data/lib/jpie/errors.rb +70 -0
  17. data/lib/jpie/generators/resource_generator.rb +39 -0
  18. data/lib/jpie/generators/templates/resource.rb.erb +12 -0
  19. data/lib/jpie/railtie.rb +36 -0
  20. data/lib/jpie/resource/attributable.rb +98 -0
  21. data/lib/jpie/resource/inferrable.rb +43 -0
  22. data/lib/jpie/resource/sortable.rb +93 -0
  23. data/lib/jpie/resource.rb +107 -0
  24. data/lib/jpie/serializer.rb +205 -0
  25. data/lib/{json_api → jpie}/version.rb +2 -2
  26. data/lib/jpie.rb +23 -3
  27. metadata +145 -50
  28. data/.gitignore +0 -21
  29. data/.rspec +0 -3
  30. data/.travis.yml +0 -7
  31. data/Gemfile +0 -21
  32. data/Gemfile.lock +0 -312
  33. data/bin/console +0 -15
  34. data/bin/setup +0 -8
  35. data/kiln/app/resources/user_message_resource.rb +0 -2
  36. data/lib/json_api/active_storage/deserialization.rb +0 -106
  37. data/lib/json_api/active_storage/detection.rb +0 -74
  38. data/lib/json_api/active_storage/serialization.rb +0 -32
  39. data/lib/json_api/configuration.rb +0 -58
  40. data/lib/json_api/controllers/base_controller.rb +0 -26
  41. data/lib/json_api/controllers/concerns/controller_helpers.rb +0 -223
  42. data/lib/json_api/controllers/concerns/resource_actions.rb +0 -657
  43. data/lib/json_api/controllers/relationships_controller.rb +0 -504
  44. data/lib/json_api/controllers/resources_controller.rb +0 -6
  45. data/lib/json_api/errors/parameter_not_allowed.rb +0 -19
  46. data/lib/json_api/railtie.rb +0 -75
  47. data/lib/json_api/resources/active_storage_blob_resource.rb +0 -11
  48. data/lib/json_api/resources/resource.rb +0 -238
  49. data/lib/json_api/resources/resource_loader.rb +0 -35
  50. data/lib/json_api/routing.rb +0 -72
  51. data/lib/json_api/serialization/deserializer.rb +0 -362
  52. data/lib/json_api/serialization/serializer.rb +0 -320
  53. data/lib/json_api/support/active_storage_support.rb +0 -85
  54. data/lib/json_api/support/collection_query.rb +0 -406
  55. data/lib/json_api/support/instrumentation.rb +0 -42
  56. data/lib/json_api/support/param_helpers.rb +0 -51
  57. data/lib/json_api/support/relationship_guard.rb +0 -16
  58. data/lib/json_api/support/relationship_helpers.rb +0 -74
  59. data/lib/json_api/support/resource_identifier.rb +0 -87
  60. data/lib/json_api/support/responders.rb +0 -100
  61. data/lib/json_api/support/response_helpers.rb +0 -10
  62. data/lib/json_api/support/sort_parsing.rb +0 -21
  63. data/lib/json_api/support/type_conversion.rb +0 -21
  64. data/lib/json_api/testing/test_helper.rb +0 -76
  65. data/lib/json_api/testing.rb +0 -3
  66. data/lib/json_api.rb +0 -50
  67. data/lib/rubocop/cop/custom/hash_value_omission.rb +0 -53
@@ -1,406 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module JSONAPI
4
- class CollectionQuery
5
- attr_reader :scope, :total_count, :pagination_applied
6
-
7
- def initialize(scope, definition:, model_class:, filter_params:, sort_params:, page_params:)
8
- @scope = scope
9
- @definition = definition
10
- @model_class = model_class
11
- @filter_params = filter_params
12
- @sort_params = sort_params
13
- @page_params = page_params
14
- @pagination_applied = page_params.present?
15
- end
16
-
17
- def execute
18
- # Apply filtering
19
- @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
- has_virtual_sort = sort_params.any? { |sort_field| virtual_attribute_sort?(sort_field) }
24
- @total_count = @scope.count unless has_virtual_sort
25
-
26
- # Apply sorting (may convert scope to array if virtual attributes are involved)
27
- @scope = apply_sorting(@scope)
28
-
29
- # Recalculate total count if we converted to array for virtual sorting
30
- @total_count = @scope.count if has_virtual_sort && @scope.is_a?(Array)
31
-
32
- # Apply pagination
33
- @scope = apply_pagination
34
-
35
- self
36
- end
37
-
38
- private
39
-
40
- attr_reader :definition, :model_class, :filter_params, :sort_params, :page_params
41
-
42
- def apply_filtering
43
- # Apply nested relationship filters first (they require joins)
44
- scope = apply_nested_relationship_filters(@scope)
45
-
46
- # Then apply regular filters (column-aware operators or model scopes)
47
- apply_regular_filters(scope)
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
- end
406
- end
@@ -1,42 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module JSONAPI
4
- module Instrumentation
5
- def self.enabled?
6
- defined?(Rails) && Rails.respond_to?(:event) && Rails.event.respond_to?(:notify)
7
- end
8
-
9
- def self.resource_event(action:, resource_type:, resource_id:, changes: {})
10
- return unless enabled?
11
-
12
- Rails.event.tagged("jsonapi") do
13
- Rails.event.notify(
14
- "jsonapi.#{resource_type}.#{action}",
15
- resource_type:,
16
- resource_id:,
17
- changes: changes.compact
18
- )
19
- end
20
- end
21
-
22
- def self.relationship_event(action:, resource_type:, resource_id:, relationship_name:, related_ids: nil,
23
- related_type: nil)
24
- return unless enabled?
25
-
26
- payload = {
27
- resource_type:,
28
- resource_id:,
29
- relationship_name:
30
- }
31
- payload[:related_type] = related_type if related_type
32
- payload[:related_ids] = Array(related_ids) if related_ids
33
-
34
- Rails.event.tagged("jsonapi", "relationship") do
35
- Rails.event.notify(
36
- "jsonapi.#{resource_type}.relationship.#{action}",
37
- payload
38
- )
39
- end
40
- end
41
- end
42
- end
@@ -1,51 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "cgi"
4
-
5
- module JSONAPI
6
- module ParamHelpers
7
- module_function
8
-
9
- def flatten_filter_hash(hash, parent_key = nil, accumulator = {})
10
- hash.each do |key, value|
11
- next unless key
12
-
13
- full_key = parent_key ? "#{parent_key}.#{key}" : key.to_s
14
-
15
- if value.is_a?(Hash)
16
- flatten_filter_hash(value, full_key, accumulator)
17
- else
18
- accumulator[full_key] = value
19
- end
20
- end
21
-
22
- accumulator
23
- end
24
-
25
- def deep_symbolize_params(params)
26
- if params.is_a?(ActionController::Parameters)
27
- params.to_unsafe_h.deep_symbolize_keys
28
- else
29
- params.to_h.deep_symbolize_keys
30
- end
31
- end
32
-
33
- def build_query_string(query_params)
34
- query_parts = []
35
- query_params.each do |key, value|
36
- query_parts.concat(build_query_parts_for_param(key, value))
37
- end
38
- query_parts.join("&")
39
- end
40
-
41
- def build_query_parts_for_param(key, value)
42
- if value.is_a?(Hash)
43
- value.map { |k, v| "#{CGI.escape(key.to_s)}[#{CGI.escape(k.to_s)}]=#{CGI.escape(v.to_s)}" }
44
- elsif value.is_a?(Array)
45
- value.map { |v| "#{CGI.escape(key.to_s)}=#{CGI.escape(v.to_s)}" }
46
- else
47
- ["#{CGI.escape(key.to_s)}=#{CGI.escape(value.to_s)}"]
48
- end
49
- end
50
- end
51
- end
@@ -1,16 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "json_api/errors/parameter_not_allowed"
4
-
5
- module JSONAPI
6
- module RelationshipGuard
7
- module_function
8
-
9
- def ensure_writable!(association, error_target, readonly: false)
10
- return unless association
11
- return unless readonly
12
-
13
- raise JSONAPI::Exceptions::ParameterNotAllowed, [error_target]
14
- end
15
- end
16
- end
@@ -1,74 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module JSONAPI
4
- module RelationshipHelpers
5
- module_function
6
-
7
- # Delegate to TypeConversion
8
- def type_to_class_name(type)
9
- TypeConversion.type_to_class_name(type)
10
- end
11
-
12
- def model_type_name(model_class)
13
- TypeConversion.model_type_name(model_class)
14
- end
15
-
16
- def resource_type_name(definition_class)
17
- TypeConversion.resource_type_name(definition_class)
18
- end
19
-
20
- # Delegate to SortParsing
21
- def extract_sort_field_name(sort_field)
22
- SortParsing.extract_sort_field_name(sort_field)
23
- end
24
-
25
- def extract_sort_direction(sort_field)
26
- SortParsing.extract_sort_direction(sort_field)
27
- end
28
-
29
- # Delegate to ResourceIdentifier
30
- def serialize_resource_identifier(
31
- record, association: nil, resource_class: nil, use_instance_class: false, base_resource_class: nil
32
- )
33
- ResourceIdentifier.serialize_identifier(
34
- record,
35
- association:,
36
- definition: resource_class,
37
- use_instance_class:
38
- )
39
- end
40
-
41
- def extract_id_from_identifier(identifier)
42
- ResourceIdentifier.extract_id(identifier)
43
- end
44
-
45
- def extract_type_from_identifier(identifier)
46
- ResourceIdentifier.extract_type(identifier)
47
- end
48
-
49
- def resolve_and_find_related_resource(identifier, association:, resource_class:, relationship_name:)
50
- ResourceIdentifier.resolve_and_find_related_record(
51
- identifier,
52
- association:,
53
- definition: resource_class,
54
- relationship_name:
55
- )
56
- end
57
-
58
- def polymorphic_association?(definition, relationship_name)
59
- ResourceIdentifier.polymorphic_association?(definition, relationship_name)
60
- end
61
-
62
- def sti_subclass?(instance_class, association_class)
63
- ResourceIdentifier.sti_subclass?(instance_class, association_class)
64
- end
65
-
66
- def polymorphic_association_for_association?(association, definition)
67
- ResourceIdentifier.polymorphic_association_for_association?(association, definition)
68
- end
69
-
70
- def find_relationship_definition(definition, relationship_name)
71
- definition.relationship_definitions.find { |r| r[:name].to_s == relationship_name.to_s }
72
- end
73
- end
74
- end
@@ -1,87 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module JSONAPI
4
- module ResourceIdentifier
5
- module_function
6
-
7
- def serialize_identifier(record, association:, definition:, use_instance_class: false)
8
- model_class = determine_model_class(record, association:, definition:,
9
- use_instance_class:)
10
- related_definition = JSONAPI::ResourceLoader.find_for_model(model_class)
11
- related_type = TypeConversion.resource_type_name(related_definition)
12
-
13
- { type: related_type, id: record.id.to_s }
14
- end
15
-
16
- def extract_id(identifier)
17
- identifier[:id].to_s.presence
18
- end
19
-
20
- def extract_type(identifier)
21
- identifier[:type]
22
- end
23
-
24
- def resolve_and_find_related_record(identifier, association:, definition:, relationship_name:)
25
- type = extract_type(identifier)
26
- id = extract_id(identifier)
27
-
28
- raise ArgumentError, "Missing type or id in relationship data" unless type && id
29
-
30
- is_polymorphic = polymorphic_association?(definition, relationship_name)
31
-
32
- unless is_polymorphic
33
- expected_type = TypeConversion.model_type_name(association.klass)
34
- if type != expected_type
35
- raise ArgumentError, "Invalid relationship type: expected #{expected_type}, got #{type}"
36
- end
37
- end
38
-
39
- begin
40
- related_model_class = if is_polymorphic
41
- TypeConversion.type_to_class_name(type).constantize
42
- else
43
- association.klass
44
- end
45
-
46
- related_model_class.find(id)
47
- rescue ActiveRecord::RecordNotFound
48
- raise ArgumentError, "Related resource not found: #{type} with id #{id}"
49
- rescue NameError
50
- raise ArgumentError, "Invalid relationship type: #{type} does not correspond to a valid model class"
51
- end
52
- end
53
-
54
- def polymorphic_association?(definition, relationship_name)
55
- relationship_def = definition.relationship_definitions.find do |r|
56
- r[:name].to_s == relationship_name.to_s
57
- end
58
- return false unless relationship_def
59
-
60
- relationship_def[:options][:polymorphic] == true
61
- end
62
-
63
- def sti_subclass?(instance_class, association_class)
64
- return false unless instance_class.respond_to?(:base_class)
65
-
66
- instance_class.base_class == association_class && instance_class != association_class
67
- end
68
-
69
- def determine_model_class(record, association:, definition:, use_instance_class:)
70
- if association && (polymorphic_association_for_association?(association, definition) ||
71
- (use_instance_class && sti_subclass?(record.class, association.klass)))
72
- record.class
73
- elsif association
74
- association.klass
75
- else
76
- record.class
77
- end
78
- end
79
-
80
- def polymorphic_association_for_association?(association, definition)
81
- return false unless definition
82
-
83
- relationship_name = association.name
84
- polymorphic_association?(definition, relationship_name)
85
- end
86
- end
87
- end