jpie 1.5.1 → 2.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.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +1 -0
  3. data/Gemfile.lock +29 -3
  4. data/README.md +149 -2
  5. data/jpie.gemspec +4 -2
  6. data/lib/json_api/configuration.rb +49 -11
  7. data/lib/json_api/controllers/concerns/controller_helpers/error_rendering.rb +6 -7
  8. data/lib/json_api/controllers/concerns/controller_helpers/resource_setup.rb +2 -3
  9. data/lib/json_api/controllers/concerns/relationships/serialization.rb +42 -1
  10. data/lib/json_api/controllers/concerns/relationships/updating.rb +1 -5
  11. data/lib/json_api/controllers/concerns/resource_actions/crud_helpers.rb +0 -8
  12. data/lib/json_api/controllers/concerns/resource_actions/field_validation.rb +0 -39
  13. data/lib/json_api/controllers/concerns/resource_actions/filter_validation.rb +3 -7
  14. data/lib/json_api/controllers/concerns/resource_actions/include_preloading.rb +89 -0
  15. data/lib/json_api/controllers/concerns/resource_actions/include_validation.rb +50 -0
  16. data/lib/json_api/controllers/concerns/resource_actions/resource_loading.rb +3 -3
  17. data/lib/json_api/controllers/concerns/resource_actions/serialization.rb +8 -25
  18. data/lib/json_api/controllers/concerns/resource_actions.rb +9 -10
  19. data/lib/json_api/controllers/relationships_controller.rb +2 -3
  20. data/lib/json_api/errors/parameter_not_allowed.rb +0 -5
  21. data/lib/json_api/rack/n1_detection.rb +41 -0
  22. data/lib/json_api/rack/query_tracking.rb +102 -0
  23. data/lib/json_api/railtie.rb +34 -3
  24. data/lib/json_api/resources/resource.rb +0 -4
  25. data/lib/json_api/serialization/concerns/include_filtering.rb +42 -0
  26. data/lib/json_api/serialization/concerns/includes_serialization.rb +9 -11
  27. data/lib/json_api/serialization/concerns/links_serialization.rb +12 -4
  28. data/lib/json_api/serialization/concerns/relationship_processing.rb +1 -5
  29. data/lib/json_api/support/collection_query.rb +1 -2
  30. data/lib/json_api/support/concerns/condition_building.rb +5 -15
  31. data/lib/json_api/support/concerns/nested_filters.rb +1 -1
  32. data/lib/json_api/support/concerns/polymorphic_filters.rb +1 -1
  33. data/lib/json_api/support/concerns/regular_filters.rb +3 -21
  34. data/lib/json_api/support/correlation_id.rb +16 -0
  35. data/lib/json_api/support/filter_parsing.rb +18 -0
  36. data/lib/json_api/support/header_warning_subscriber.rb +17 -0
  37. data/lib/json_api/support/n1_log_subscriber.rb +38 -0
  38. data/lib/json_api/support/param_helpers.rb +4 -0
  39. data/lib/json_api/support/prosopite_instrumentation_logger.rb +56 -0
  40. data/lib/json_api/support/query_tracking_log_subscriber.rb +57 -0
  41. data/lib/json_api/support/query_tracking_subscriber.rb +76 -0
  42. data/lib/json_api/support/relationship_guard.rb +1 -1
  43. data/lib/json_api/support/resource_identifier.rb +2 -1
  44. data/lib/json_api/support/responders.rb +1 -1
  45. data/lib/json_api/version.rb +1 -1
  46. data/lib/json_api.rb +9 -1
  47. metadata +49 -13
  48. data/lib/json_api/controllers/concerns/resource_actions/preloading.rb +0 -80
  49. data/lib/json_api/resources/concerns/eager_load_dsl.rb +0 -50
  50. data/lib/json_api/resources/concerns/preload_dsl.rb +0 -49
  51. data/lib/json_api/support/response_helpers.rb +0 -10
@@ -28,12 +28,12 @@ module JSONAPI
28
28
  def load_resource_record
29
29
  return unless params[:id] && @resource_class
30
30
 
31
- @resource = @resource_class.records.find(params[:id])
31
+ scope = scope_with_includes(@resource_class.records)
32
+ @resource = scope.find(params[:id])
32
33
  end
33
34
 
34
35
  def render_record_not_found
35
- render_jsonapi_error(
36
- status: 404,
36
+ render_not_found_error(
37
37
  title: "Record Not Found",
38
38
  detail: "Could not find #{@resource_name} with id '#{params[:id]}'",
39
39
  )
@@ -4,33 +4,31 @@ module JSONAPI
4
4
  module ResourceActions
5
5
  module Serialization
6
6
  extend ActiveSupport::Concern
7
+ include IncludePreloading
7
8
 
8
9
  def serialize_resource(resource)
9
- run_preload_hook([resource])
10
10
  JSONAPI::Serializer.new(resource).to_hash(
11
11
  include: parse_include_param,
12
12
  fields: parse_fields_param,
13
13
  document_meta: jsonapi_document_meta,
14
14
  )
15
- ensure
16
- clear_preload_data
17
15
  end
18
16
 
19
17
  def serialize_collection(resources)
18
+ includes = parse_include_param
19
+ fields = parse_fields_param
20
+ resources = scope_with_includes(resources)
20
21
  resources_array = resources.to_a
21
- run_preload_hook(resources_array)
22
22
 
23
- data, all_included = serialize_resources_with_includes(resources_array)
23
+ preload_included_resource_associations(resources_array, includes)
24
+
25
+ data, all_included = serialize_resources_with_includes(resources_array, includes, fields)
24
26
  build_collection_response(data, all_included)
25
- ensure
26
- clear_preload_data
27
27
  end
28
28
 
29
29
  private
30
30
 
31
- def serialize_resources_with_includes(resources)
32
- includes = parse_include_param
33
- fields = parse_fields_param
31
+ def serialize_resources_with_includes(resources, includes, fields)
34
32
  all_included = []
35
33
  processed = Set.new
36
34
 
@@ -71,21 +69,6 @@ module JSONAPI
71
69
 
72
70
  result
73
71
  end
74
-
75
- def run_preload_hook(records)
76
- return unless resource_class.respond_to?(:preload_for_serialization)
77
-
78
- preloaded = resource_class.preload_for_serialization(records, jsonapi_context)
79
- resource_class.preloaded_data = preloaded if preloaded.present?
80
- end
81
-
82
- def clear_preload_data
83
- resource_class.clear_preloaded_data! if resource_class.respond_to?(:clear_preloaded_data!)
84
- end
85
-
86
- def jsonapi_context
87
- { current_user: respond_to?(:current_user) ? current_user : nil }
88
- end
89
72
  end
90
73
  end
91
74
  end
@@ -2,7 +2,8 @@
2
2
 
3
3
  require_relative "resource_actions/filter_validation"
4
4
  require_relative "resource_actions/field_validation"
5
- require_relative "resource_actions/preloading"
5
+ require_relative "resource_actions/include_validation"
6
+ require_relative "resource_actions/include_preloading"
6
7
  require_relative "resource_actions/serialization"
7
8
  require_relative "resource_actions/pagination"
8
9
  require_relative "resource_actions/type_validation"
@@ -15,7 +16,7 @@ module JSONAPI
15
16
  include ActiveStorageSupport
16
17
  include FilterValidation
17
18
  include FieldValidation
18
- include Preloading
19
+ include IncludeValidation
19
20
  include Serialization
20
21
  include Pagination
21
22
  include TypeValidation
@@ -30,11 +31,10 @@ module JSONAPI
30
31
  before_action :validate_include_param, only: %i[index show]
31
32
  before_action :validate_resource_type!, only: %i[create update]
32
33
  before_action :validate_resource_id!, only: [:update]
33
- before_action :preload_includes, only: %i[index show]
34
34
  end
35
35
 
36
36
  def index
37
- scope = apply_authorization_scope(@preloaded_resources || resource_class.records, action: :index)
37
+ scope = apply_authorization_scope(@resource_class.records, action: :index)
38
38
  query = build_query(scope)
39
39
  @total_count = query.total_count
40
40
  @pagination_applied = query.pagination_applied
@@ -42,9 +42,8 @@ module JSONAPI
42
42
  end
43
43
 
44
44
  def show
45
- resource = @preloaded_resource || @resource
46
- authorize_resource_action!(resource, action: :show)
47
- render json: serialize_resource(resource), status: :ok
45
+ authorize_resource_action!(@resource, action: :show)
46
+ render json: serialize_resource(@resource), status: :ok
48
47
  end
49
48
 
50
49
  def create
@@ -54,7 +53,7 @@ module JSONAPI
54
53
  save_created(resource)
55
54
  rescue ArgumentError => e
56
55
  render_create_error(e)
57
- rescue JSONAPI::Exceptions::ParameterNotAllowed, ActiveSupport::MessageVerifier::InvalidSignature => e
56
+ rescue JSONAPI::Errors::ParameterNotAllowed, ActiveSupport::MessageVerifier::InvalidSignature => e
58
57
  handle_create_exception(e)
59
58
  end
60
59
 
@@ -66,7 +65,7 @@ module JSONAPI
66
65
 
67
66
  def handle_create_exception(error)
68
67
  case error
69
- when JSONAPI::Exceptions::ParameterNotAllowed then render_parameter_not_allowed_error(error)
68
+ when JSONAPI::Errors::ParameterNotAllowed then render_parameter_not_allowed_error(error)
70
69
  when ActiveSupport::MessageVerifier::InvalidSignature then render_signed_id_error(error)
71
70
  end
72
71
  end
@@ -77,7 +76,7 @@ module JSONAPI
77
76
  save_updated(params_hash, attachments)
78
77
  rescue ArgumentError => e
79
78
  render_invalid_relationship_error(e)
80
- rescue JSONAPI::Exceptions::ParameterNotAllowed => e
79
+ rescue JSONAPI::Errors::ParameterNotAllowed => e
81
80
  render_parameter_not_allowed_error(e)
82
81
  rescue ActiveSupport::MessageVerifier::InvalidSignature => e
83
82
  render_signed_id_error(e)
@@ -26,7 +26,6 @@ module JSONAPI
26
26
  before_action :set_relationship_name
27
27
  before_action :validate_relationship_exists
28
28
  before_action :validate_sort_param, only: [:show]
29
- skip_before_action :validate_resource_type!, :validate_resource_id!
30
29
 
31
30
  def show
32
31
  authorize_resource_action!(@resource, action: :show, context: { relationship: @relationship_name })
@@ -40,7 +39,7 @@ module JSONAPI
40
39
  save_and_render_relationship(relationship_data)
41
40
  rescue ArgumentError => e
42
41
  render_invalid_relationship_error(e)
43
- rescue JSONAPI::Exceptions::ParameterNotAllowed => e
42
+ rescue JSONAPI::Errors::ParameterNotAllowed => e
44
43
  render_parameter_not_allowed_error(e)
45
44
  end
46
45
 
@@ -53,7 +52,7 @@ module JSONAPI
53
52
  finalize_relationship_removal(relationship_data)
54
53
  rescue ArgumentError => e
55
54
  render_invalid_relationship_error(e)
56
- rescue JSONAPI::Exceptions::ParameterNotAllowed => e
55
+ rescue JSONAPI::Errors::ParameterNotAllowed => e
57
56
  render_parameter_not_allowed_error(e)
58
57
  end
59
58
 
@@ -11,9 +11,4 @@ module JSONAPI
11
11
  end
12
12
  end
13
13
  end
14
-
15
- # Backward compatibility alias
16
- module Exceptions
17
- ParameterNotAllowed = Errors::ParameterNotAllowed
18
- end
19
14
  end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prosopite"
4
+
5
+ module JSONAPI
6
+ module Rack
7
+ class N1Detection
8
+ def initialize(app)
9
+ @app = app
10
+ end
11
+
12
+ def call(env)
13
+ return @app.call(env) unless JSONAPI.configuration.n1_detection_enabled
14
+ return @app.call(env) unless jsonapi_request?(env)
15
+ return @app.call(env) unless postgres?
16
+
17
+ Thread.current[:jpie_request_env] = env
18
+ Prosopite.custom_logger = JSONAPI::Support::ProsopiteInstrumentationLogger.new
19
+
20
+ result = Prosopite.scan { @app.call(env) }
21
+ result
22
+ ensure
23
+ Prosopite.finish
24
+ Thread.current[:jpie_request_env] = nil
25
+ end
26
+
27
+ private
28
+
29
+ def jsonapi_request?(env)
30
+ env["HTTP_ACCEPT"].to_s.include?("application/vnd.api+json")
31
+ end
32
+
33
+ def postgres?
34
+ return false unless defined?(ActiveRecord) && ActiveRecord::Base.connection_db_config
35
+
36
+ adapter = ActiveRecord::Base.connection_db_config.adapter
37
+ adapter.to_s.include?("postgresql")
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JSONAPI
4
+ module Rack
5
+ class QueryTracking
6
+ def initialize(app)
7
+ @app = app
8
+ end
9
+
10
+ def call(env)
11
+ return @app.call(env) unless JSONAPI.configuration.query_tracking_enabled
12
+ return @app.call(env) unless jsonapi_request?(env)
13
+
14
+ call_with_tracking(env)
15
+ ensure
16
+ emit_excessive_queries_if_needed
17
+ Thread.current[:jpie_query_tracking] = nil
18
+ Thread.current[:jpie_warnings] = nil
19
+ end
20
+
21
+ private
22
+
23
+ def call_with_tracking(env)
24
+ Thread.current[:jpie_query_tracking] = { count: 0, queries: [], env: env }
25
+ Thread.current[:jpie_warnings] = [] if JSONAPI.configuration.jpie_headers_enabled
26
+
27
+ status, headers, body = @app.call(env)
28
+ add_jpie_headers(headers) if JSONAPI.configuration.jpie_headers_enabled
29
+
30
+ [status, headers, body]
31
+ end
32
+
33
+ def jsonapi_request?(env)
34
+ env["HTTP_ACCEPT"].to_s.include?("application/vnd.api+json")
35
+ end
36
+
37
+ def emit_excessive_queries_if_needed
38
+ tracking = Thread.current[:jpie_query_tracking]
39
+ return if tracking.nil?
40
+
41
+ count = tracking[:count]
42
+ threshold = JSONAPI.configuration.query_count_threshold
43
+ return if count <= threshold
44
+ return unless defined?(Rails) && Rails.respond_to?(:event)
45
+
46
+ notify_excessive_queries(tracking, count, threshold)
47
+ end
48
+
49
+ def notify_excessive_queries(tracking, count, threshold)
50
+ cap = JSONAPI.configuration.query_tracking_queries_cap
51
+ queries = tracking[:queries].first(cap)
52
+ payload = build_excessive_queries_payload(tracking[:env], count, threshold, queries)
53
+
54
+ Rails.event.tagged("jsonapi", "excessive_queries") do
55
+ Rails.event.notify("jpie.excessive_queries_detected", payload)
56
+ end
57
+ end
58
+
59
+ def build_excessive_queries_payload(env, count, threshold, queries)
60
+ build_payload(env).merge(
61
+ query_count: count,
62
+ threshold: threshold,
63
+ queries: queries,
64
+ )
65
+ end
66
+
67
+ def build_payload(env)
68
+ base = env.nil? ? {} : request_payload_from_env(env)
69
+ base.merge(correlation_id: JSONAPI::Support::CorrelationId.resolve)
70
+ end
71
+
72
+ def request_payload_from_env(env)
73
+ req = ActionDispatch::Request.new(env)
74
+ params = req.params
75
+ {
76
+ path: req.path,
77
+ method: req.request_method,
78
+ include: params[:include],
79
+ resource_type: params[:resource_type],
80
+ resource_id: params[:id],
81
+ }
82
+ end
83
+
84
+ def add_jpie_headers(headers)
85
+ tracking = Thread.current[:jpie_query_tracking]
86
+ return if tracking.nil?
87
+
88
+ count = tracking[:count]
89
+ headers["X-JPie-Query-Count"] = count.to_s
90
+
91
+ warning_str = build_performance_warning_header(count)
92
+ headers["X-JPie-Performance-Warning"] = warning_str if warning_str.present?
93
+ end
94
+
95
+ def build_performance_warning_header(count)
96
+ warnings = (Thread.current[:jpie_warnings] || []).dup
97
+ warnings << "excessive_queries" if count > JSONAPI.configuration.query_count_threshold
98
+ warnings.uniq.join(",").presence
99
+ end
100
+ end
101
+ end
102
+ end
@@ -16,14 +16,45 @@ module JSONAPI
16
16
  Mime::Type.register "application/vnd.api+json", :jsonapi
17
17
  end
18
18
 
19
+ initializer "json_api.n1_log_subscriber", after: "action_dispatch.configure" do
20
+ if Rails.respond_to?(:event) && JSONAPI.configuration.n1_detection_enabled
21
+ Rails.event.subscribe(JSONAPI::Support::N1LogSubscriber.new)
22
+ end
23
+ end
24
+
25
+ initializer "json_api.n1_detection_middleware", after: "action_dispatch.configure" do |app|
26
+ if app.config.respond_to?(:server_timing) && app.config.server_timing
27
+ app.config.middleware.insert_after ActionDispatch::ServerTiming, JSONAPI::Rack::N1Detection
28
+ else
29
+ app.config.middleware.use JSONAPI::Rack::N1Detection
30
+ end
31
+ end
32
+
33
+ initializer "json_api.query_tracking_subscriber", after: "action_dispatch.configure" do
34
+ ActiveSupport::Notifications.subscribe("sql.active_record", JSONAPI::Support::QueryTrackingSubscriber.new)
35
+ end
36
+
37
+ initializer "json_api.query_tracking_log_subscriber", after: "action_dispatch.configure" do
38
+ if Rails.respond_to?(:event) && JSONAPI.configuration.query_tracking_enabled
39
+ Rails.event.subscribe(JSONAPI::Support::QueryTrackingLogSubscriber.new)
40
+ end
41
+ end
42
+
43
+ initializer "json_api.header_warning_subscriber", after: "action_dispatch.configure" do
44
+ if Rails.respond_to?(:event) && JSONAPI.configuration.jpie_headers_enabled
45
+ Rails.event.subscribe(JSONAPI::Support::HeaderWarningSubscriber.new)
46
+ end
47
+ end
48
+
49
+ initializer "json_api.query_tracking_middleware", after: "json_api.n1_detection_middleware" do |app|
50
+ app.config.middleware.insert_after JSONAPI::Rack::N1Detection, JSONAPI::Rack::QueryTracking
51
+ end
52
+
19
53
  initializer "json_api.routes" do |_app|
20
54
  require "json_api/routing"
21
55
  ActionDispatch::Routing::Mapper.include JSONAPI::Routing
22
56
  end
23
57
 
24
- # Removed eager_load_namespaces registration - JSONAPI module doesn't implement eager_load!
25
- # Controllers and resources are autoloaded via Zeitwerk
26
-
27
58
  initializer "json_api.parameter_parsing", after: "action_dispatch.configure" do |_app|
28
59
  ActionDispatch::Request.parameter_parsers[:jsonapi] = lambda do |raw_post|
29
60
  ActiveSupport::JSON.decode(raw_post)
@@ -6,8 +6,6 @@ require_relative "concerns/sortable_fields_dsl"
6
6
  require_relative "concerns/relationships_dsl"
7
7
  require_relative "concerns/meta_dsl"
8
8
  require_relative "concerns/model_class_helpers"
9
- require_relative "concerns/eager_load_dsl"
10
- require_relative "concerns/preload_dsl"
11
9
 
12
10
  module JSONAPI
13
11
  class Resource
@@ -17,8 +15,6 @@ module JSONAPI
17
15
  include Resources::RelationshipsDsl
18
16
  include Resources::MetaDsl
19
17
  include Resources::ModelClassHelpers
20
- include Resources::EagerLoadDsl
21
- include Resources::PreloadDsl
22
18
 
23
19
  class << self
24
20
  attr_accessor :type_format
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JSONAPI
4
+ module Serialization
5
+ module IncludeFiltering
6
+ def filter_loaded_records(association, related_klass)
7
+ loaded_array = association.target.respond_to?(:to_a) ? association.target.to_a : Array(association.target)
8
+ return [] if loaded_array.empty?
9
+
10
+ resource_scope = ResourceLoader.find_for_model(related_klass).records
11
+ return loaded_array if resource_scope.where_clause.empty?
12
+
13
+ filter_by_where_hash(loaded_array, resource_scope, related_klass)
14
+ end
15
+
16
+ def filter_by_where_hash(loaded_array, resource_scope, related_klass)
17
+ hash = where_values_hash_for_scope(resource_scope, related_klass)
18
+ return filter_by_query(loaded_array, resource_scope) if hash.blank?
19
+
20
+ loaded_array.select { |r| record_matches_where_hash?(r, hash) }
21
+ end
22
+
23
+ def where_values_hash_for_scope(resource_scope, related_klass)
24
+ resource_scope.where_values_hash(related_klass.table_name)
25
+ rescue StandardError
26
+ {}
27
+ end
28
+
29
+ def record_matches_where_hash?(record, hash)
30
+ hash.all? do |attr, val|
31
+ r_val = record.read_attribute(attr)
32
+ val.is_a?(Array) ? val.include?(r_val) : r_val == val
33
+ end
34
+ end
35
+
36
+ def filter_by_query(loaded_array, resource_scope)
37
+ valid_ids = resource_scope.where(id: loaded_array.filter_map(&:id)).pluck(:id).to_set
38
+ loaded_array.select { |r| valid_ids.include?(r.id) }
39
+ end
40
+ end
41
+ end
42
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "include_filtering"
4
+
3
5
  module JSONAPI
4
6
  module Serialization
5
7
  IncludeContext = Struct.new(:fields, :included_records, :processed, :all_includes, keyword_init: true)
@@ -9,6 +11,7 @@ module JSONAPI
9
11
 
10
12
  module IncludesSerialization
11
13
  include IncludePathHelpers
14
+ include IncludeFiltering
12
15
 
13
16
  def serialize_included(includes, fields = {})
14
17
  all_includes = normalize_include_paths(includes)
@@ -84,21 +87,16 @@ module JSONAPI
84
87
  related_klass = association.klass
85
88
  return [] if related_klass.blank?
86
89
 
87
- scope = build_scoped_relation(related_klass, association)
88
- association.loaded? ? scope_loaded_to_records_scope(association, scope) : scope.to_a
89
- end
90
-
91
- def scope_loaded_to_records_scope(association, scope)
92
- loaded_array = association.target.respond_to?(:to_a) ? association.target.to_a : Array(association.target)
93
- loaded_ids = loaded_array.filter_map(&:id)
94
- return [] if loaded_ids.empty?
95
-
96
- scope.where(id: loaded_ids).to_a
90
+ if association.loaded?
91
+ filter_loaded_records(association, related_klass)
92
+ else
93
+ build_scoped_relation(related_klass, association).to_a
94
+ end
97
95
  end
98
96
 
99
97
  def build_scoped_relation(related_klass, association)
100
98
  related_base_scope = ResourceLoader.find_for_model(related_klass).records
101
- related_base_scope.merge(association.scope)
99
+ association.scope.merge(related_base_scope)
102
100
  end
103
101
 
104
102
  def get_active_storage_records(current_record, association_name)
@@ -22,17 +22,25 @@ module JSONAPI
22
22
  end
23
23
 
24
24
  def variant_or_blob_path
25
- return rails_blob_path unless parent_record && association_name && record.content_type&.start_with?("image/")
25
+ return rails_blob_path unless image_blob_with_parent?
26
26
 
27
- parent_definition = ResourceLoader.find_for_model(parent_record.class)
28
- rel_def = RelationshipHelpers.find_relationship_definition(parent_definition, association_name)
29
- variant_opts = rel_def&.dig(:options, :variant)
27
+ variant_opts = variant_options_for_parent_association
30
28
  return rails_blob_path unless variant_opts.present?
31
29
 
32
30
  representation = record.representation(**variant_opts.symbolize_keys)
33
31
  Rails.application.routes.url_helpers.rails_representation_path(representation, only_path: true)
34
32
  end
35
33
 
34
+ def image_blob_with_parent?
35
+ parent_record && association_name && record.content_type&.start_with?("image/")
36
+ end
37
+
38
+ def variant_options_for_parent_association
39
+ parent_definition = ResourceLoader.find_for_model(parent_record.class)
40
+ rel_def = RelationshipHelpers.find_relationship_definition(parent_definition, association_name)
41
+ rel_def&.dig(:options, :variant)
42
+ end
43
+
36
44
  def rails_blob_path
37
45
  Rails.application.routes.url_helpers.rails_blob_path(record, only_path: true)
38
46
  end
@@ -11,7 +11,7 @@ module JSONAPI
11
11
  ensure_relationship_writable!(association_name)
12
12
 
13
13
  return handle_null_relationship(attrs, param_name, association_name) if data.nil?
14
- return handle_empty_array_relationship(attrs, param_name, association_name) if empty_array?(data)
14
+ return handle_empty_array_relationship(attrs, param_name, association_name) if ParamHelpers.empty_array?(data)
15
15
 
16
16
  validate_relationship_data_format!(data, association_name)
17
17
  process_relationship_data(attrs, association_name, param_name, data)
@@ -27,10 +27,6 @@ module JSONAPI
27
27
  value_hash[:data]
28
28
  end
29
29
 
30
- def empty_array?(data)
31
- data.is_a?(Array) && data.empty?
32
- end
33
-
34
30
  def handle_null_relationship(attrs, param_name, association_name)
35
31
  if active_storage_attachment?(association_name)
36
32
  attrs[association_name.to_s] = nil
@@ -44,8 +44,7 @@ module JSONAPI
44
44
  return unless pagination_applied
45
45
 
46
46
  has_virtual_sort = sort_params.any? { |sort_field| virtual_attribute_sort?(sort_field) }
47
- @total_count = @scope.count unless has_virtual_sort
48
- @total_count = @scope.count if has_virtual_sort && @scope.is_a?(Array)
47
+ @total_count = @scope.count if !has_virtual_sort || @scope.is_a?(Array)
49
48
  end
50
49
 
51
50
  def apply_filtering
@@ -3,10 +3,13 @@
3
3
  module JSONAPI
4
4
  module Support
5
5
  module ConditionBuilding
6
+ include Support::FilterParsing
7
+
6
8
  private
7
9
 
8
- def build_condition(column, value, operator)
9
- build_arel_condition(model_class, column, value, operator)
10
+ def build_condition(model, column, value, operator)
11
+ attr = model.arel_table[column.name]
12
+ build_operator_condition(attr, value, operator)
10
13
  end
11
14
 
12
15
  def normalize_filter_value_for_model(model, column, raw_value)
@@ -19,15 +22,6 @@ module JSONAPI
19
22
  type.cast(value)
20
23
  end
21
24
 
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
25
  def build_operator_condition(attr, value, operator)
32
26
  case operator
33
27
  when :eq then attr.eq(value)
@@ -48,10 +42,6 @@ module JSONAPI
48
42
  def apply_condition(scope, condition)
49
43
  scope.where(condition)
50
44
  end
51
-
52
- def empty_filter_value?(filter_value)
53
- filter_value.respond_to?(:empty?) ? filter_value.empty? : filter_value.nil?
54
- end
55
45
  end
56
46
  end
57
47
  end
@@ -97,7 +97,7 @@ module JSONAPI
97
97
  value = normalize_filter_value_for_model(target_model, column, filter_value)
98
98
  return nil unless value
99
99
 
100
- condition = build_condition_for_model(target_model, column, value, column_filter[:operator])
100
+ condition = build_condition(target_model, column, value, column_filter[:operator])
101
101
  condition ? apply_condition(scope, condition) : nil
102
102
  end
103
103
 
@@ -51,7 +51,7 @@ module JSONAPI
51
51
  value = normalize_filter_value_for_model(model_class, context[:fk_column], attr_value)
52
52
  return scope unless value
53
53
 
54
- condition = build_condition_for_model(model_class, context[:fk_column], value, column_filter[:operator])
54
+ condition = build_condition(model_class, context[:fk_column], value, column_filter[:operator])
55
55
  condition ? apply_condition(scope, condition) : scope
56
56
  end
57
57
 
@@ -17,7 +17,7 @@ module JSONAPI
17
17
  private
18
18
 
19
19
  def apply_regular_filter(scope, filter_name, filter_value)
20
- return scope if filter_value.respond_to?(:empty?) ? filter_value.empty? : filter_value.nil?
20
+ return scope if empty_filter_value?(filter_value)
21
21
 
22
22
  column_filter = parse_column_filter(filter_name)
23
23
  if column_filter
@@ -27,16 +27,6 @@ module JSONAPI
27
27
  end
28
28
  end
29
29
 
30
- def parse_column_filter(filter_name)
31
- match = filter_name.match(/\A(.+)_(eq|match|lt|lte|gt|gte)\z/)
32
- return nil unless match
33
-
34
- column_name = match[1]
35
- operator = match[2].to_sym
36
-
37
- { column: column_name, operator: }
38
- end
39
-
40
30
  def apply_column_filter(scope, column_filter, raw_value)
41
31
  condition = build_column_condition(column_filter, raw_value)
42
32
  condition ? apply_condition(scope, condition) : scope
@@ -49,10 +39,10 @@ module JSONAPI
49
39
  column = model_class.column_for_attribute(column_filter[:column])
50
40
  return nil unless column
51
41
 
52
- value = normalize_filter_value(column, raw_value)
42
+ value = normalize_filter_value_for_model(model_class, column, raw_value)
53
43
  return nil if value.nil?
54
44
 
55
- build_condition(column, value, column_filter[:operator])
45
+ build_condition(model_class, column, value, column_filter[:operator])
56
46
  end
57
47
 
58
48
  def log_filter_error(column_filter, operator, error)
@@ -61,14 +51,6 @@ module JSONAPI
61
51
  Rails.logger.warn("Filter error for #{column_filter[:column]}_#{operator}: #{error.class} - #{error.message}")
62
52
  end
63
53
 
64
- def normalize_filter_value(column, raw_value)
65
- value = raw_value.is_a?(Array) ? raw_value.first : raw_value
66
- return nil if value.nil?
67
-
68
- type = model_class.type_for_attribute(column.name)
69
- type.cast(value)
70
- end
71
-
72
54
  def apply_scope_fallback(scope, filter_name, filter_value)
73
55
  return scope unless model_class.respond_to?(filter_name.to_sym)
74
56